From cbeb3a113b292c6786e67d896282d420ca68328a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:22:20 +0000 Subject: [PATCH 1/5] Start fix for #912, #1122 Assisted-by: Claude Code:claude-opus-5-5 From 9f0f25752f20e96d15a55f3821688b5fad8f7d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:43:33 +0000 Subject: [PATCH 2/5] Stop wiring pylocks that Pipenv installs from A pylock.toml (or pylock..toml) beside a Pipfile is read by Pipenv, not by a PEP 751 installer, and every pylock code path treated it as installer-neutral: - Vendored scans of a `use_pylock = true` project (Pipfile, Pipfile.lock and pylock.toml) wired only pylock.toml. Pipenv installs from Pipfile.lock when both exist, so every `pipenv sync` and `install --deploy` got the unpatched release after a "success" run (#1122). The router now wires Pipfile.lock and names the pylock in the pypi_multiple_lockfiles warning. - Hosted and vendored scans of a pylock-only Pipenv checkout wrote an `archive` entry that Pipenv's pylock reader drops, so `pipenv sync` installed the upstream release while the scan reported success, and vendored VEX attested not_affected (#912). Both modes now refuse with a `pipenv lock` pointer (redirect_pipenv_pylock_unsupported / pypi_pipenv_pylock_unsupported), and VEX discovery no longer trusts a Socket reference in such a pylock. The rule lives in one predicate, utils::python_lock::pipenv_reads_pylock, shared by the hosted rewriter, the vendored router and VEX discovery. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 32 +++- .../src/patch/redirect/pipenv_pylock_tests.rs | 129 ++++++++++++++ .../src/utils/python_lock.rs | 27 ++- crates/socket-patch-core/src/vendor/pypi.rs | 161 +++++++++++++++++- .../src/vex/discover/pypi_locks.rs | 74 +++++++- 5 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index cf21a0c54..b52fa7a0c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -48,6 +48,8 @@ mod lock_index_equivalence_tests; pub mod npmrc; mod pdm; mod pipenv; +#[cfg(test)] +mod pipenv_pylock_tests; pub mod presence; // The pnpm hosted planner lives with the format's model. use crate::formats::cargo::hosted::CargoLockPlan; @@ -5185,6 +5187,24 @@ fn record_python_metadata_edit( result.files.insert(edit.path, edit.rewritten); } +/// #912: the refusal for a pylock Pipenv installs from +/// ([`pipenv_reads_pylock`]): Pipenv drops the `archive` entry a pin +/// writes, so `pipenv sync` would install the upstream release. +/// +/// [`pipenv_reads_pylock`]: crate::utils::python_lock::pipenv_reads_pylock +fn pipenv_pylock_refusal(path: &str, dep: &DepOverride) -> RewriteWarning { + RewriteWarning { + code: "redirect_pipenv_pylock_unsupported".into(), + detail: format!( + "{path} is installed by Pipenv (a Pipfile sits beside it and there is no \ + Pipfile.lock), and Pipenv keeps only the version and hashes of a pylock \ + entry, so a pin of {}=={} would be dropped and the upstream release \ + installed; run `pipenv lock` to write a Pipfile.lock and re-run the scan", + dep.name, dep.version + ), + } +} + /// Each lock is parsed once ([`PythonLockSession`]) and every dep is /// planned, refused or applied against that one document. The lock is still /// rendered after every rewritten dep: each dep's FileEdit fragments are @@ -5197,7 +5217,9 @@ fn rewrite_uv_lock( python_metadata: &BTreeMap, result: &mut RewriteResult, ) { - use crate::utils::python_lock::{is_python_lock_name, ArtifactSource, PythonLockSession}; + use crate::utils::python_lock::{ + is_python_lock_name, pipenv_reads_pylock, ArtifactSource, PythonLockSession, + }; let locks: Vec<(&String, &String)> = files .iter() @@ -5223,9 +5245,17 @@ fn rewrite_uv_lock( for (path, original) in locks { let mut content = original.clone(); let mut session = PythonLockSession::new(original); + let pipenv_reads = pipenv_reads_pylock(path, |rel| files.contains_key(rel)); for &(dep, sha256) in &usable { let artifact = ArtifactSource::Url(&dep.artifact_url); let plan = match session.plan(&content, &dep.name, &dep.version, artifact) { + Ok(Some(_)) if pipenv_reads => { + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); + result.warnings.push(pipenv_pylock_refusal(path, dep)); + continue; + } Ok(Some(plan)) => plan, Ok(None) => { result.warnings.push(RewriteWarning { diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs b/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs new file mode 100644 index 000000000..e28665eb1 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs @@ -0,0 +1,129 @@ +//! #912 / #1122: a PEP 751 `pylock*.toml` beside a `Pipfile` is read by +//! Pipenv, not by a PEP 751 installer. Pipenv's pylock reader +//! (`PylockFile.convert_to_pipenv_lockfile`) keeps only each package's +//! version, marker and wheel / sdist hashes, so the `archive` entry a hosted +//! pin writes is dropped and `pipenv sync` installs the upstream release. +//! With no `Pipfile.lock` (which Pipenv prefers when both exist) the pin is +//! refused; with one, both locks are pinned as before. + +use super::*; + +const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl"; +const UUID: &str = "aaaaaaaa-0000-4000-8000-000000000912"; +const HEX: &str = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"; +const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n"; + +fn dep() -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "six".into(), + namespace: None, + version: "1.16.0".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: UUID.into(), + artifact_url: format!( + "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/{UUID}/{WHEEL}" + ), + registry_override: None, + integrity: Integrity { + sha256: Some(HEX.into()), + ..Default::default() + }, + } +} + +/// A pylock as `pipenv lock` (use_pylock = true) writes it. +fn pylock() -> String { + format!( + "lock-version = \"1.0\"\nenvironments = []\nextras = []\ndependency-groups = []\ndefault-groups = []\ncreated-by = \"pipenv\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nwheels = [{{ name = \"{WHEEL}\", url = \"https://files.pythonhosted.org/packages/d9/5a/{WHEEL}\", hashes = {{ sha256 = \"{HEX}\" }} }}]\n\n[tool.pipenv]\ngenerated_from = \"Pipfile.lock\"\n" + ) +} + +fn pipfile_lock() -> String { + format!( + "{{\n \"_meta\": {{\n \"hash\": {{\n \"sha256\": \"unchanged\"\n }},\n \"pipfile-spec\": 6,\n \"sources\": [\n {{\n \"name\": \"pypi\",\n \"url\": \"https://pypi.org/simple\",\n \"verify_ssl\": true\n }}\n ]\n }},\n \"default\": {{\n \"six\": {{\n \"hashes\": [\n \"sha256:{HEX}\"\n ],\n \"index\": \"pypi\",\n \"version\": \"==1.16.0\"\n }}\n }},\n \"develop\": {{}}\n}}\n" + ) +} + +fn files(entries: &[(&str, String)]) -> BTreeMap { + entries + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() +} + +fn rewrite(files: &BTreeMap) -> RewriteResult { + rewrite_registry_redirect_with_pipenv_version( + files, + &[dep()], + &BTreeMap::new(), + Some(2026), + false, + ) +} + +/// #912: a pylock-only Pipenv checkout (any `pylock_name`) is refused, +/// loudly, and nothing is written or confirmed for it. +#[test] +fn pylock_read_by_pipenv_is_refused_without_pipfile_lock() { + for (pipfile, lock) in [("Pipfile", "pylock.toml"), ("Pipfile", "pylock.dev.toml")] { + let result = rewrite(&files(&[(pipfile, PIPFILE.into()), (lock, pylock())])); + assert!( + result.files.is_empty() && result.edits.is_empty(), + "{lock}: pinned {:?}", + result.files.keys().collect::>() + ); + assert!( + !result.confirmed_python_lock_uuids.contains(UUID), + "{lock}: confirmed" + ); + assert!(result.refused_python_lock_uuids.contains(UUID), "{lock}"); + let refusals: Vec<&RewriteWarning> = result + .warnings + .iter() + .filter(|w| w.code == "redirect_pipenv_pylock_unsupported") + .collect(); + assert_eq!(refusals.len(), 1, "{lock}: {:?}", result.warnings); + assert!( + refusals[0].detail.contains(lock) + && refusals[0].detail.contains("pipenv lock") + && refusals[0].detail.contains("six==1.16.0"), + "{lock}: {}", + refusals[0].detail + ); + } +} + +/// Controls: a pylock with no Pipfile beside it is pinned as before, and a +/// `use_pylock = true` project with both locks still pins both (#1122's +/// hosted control). +#[test] +fn pylock_without_a_pipenv_consumer_or_beside_pipfile_lock_still_pins() { + let result = rewrite(&files(&[("pylock.toml", pylock())])); + assert!( + result + .files + .get("pylock.toml") + .is_some_and(|t| t.contains(WHEEL) && t.contains("archive")), + "{:?}", + result.warnings + ); + assert!(result.confirmed_python_lock_uuids.contains(UUID)); + + let result = rewrite(&files(&[ + ("Pipfile", PIPFILE.into()), + ("Pipfile.lock", pipfile_lock()), + ("pylock.toml", pylock()), + ])); + for lock in ["Pipfile.lock", "pylock.toml"] { + assert!( + result.files.get(lock).is_some_and(|t| t.contains(UUID)), + "{lock}: {:?}", + result.warnings + ); + } + assert!(result + .warnings + .iter() + .all(|w| w.code != "redirect_pipenv_pylock_unsupported")); +} diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 70e848dc4..4940ba6b1 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -175,10 +175,29 @@ pub(crate) fn uv_source_location(package: &dyn TableLike) -> Option<&str> { } pub fn is_python_lock_name(name: &str) -> bool { - name == "uv.lock" - || name.ends_with(".py.lock") - || name == "pylock.toml" - || (name.starts_with("pylock.") && name.ends_with(".toml")) + name == "uv.lock" || name.ends_with(".py.lock") || is_pep751_lock_name(name) +} + +/// A PEP 751 lock name: `pylock.toml` or `pylock..toml`. +pub fn is_pep751_lock_name(name: &str) -> bool { + name == "pylock.toml" || (name.starts_with("pylock.") && name.ends_with(".toml")) +} + +/// #912 / #1122: whether Pipenv installs from the PEP 751 lock `rel` (a +/// project-relative path). A `Pipfile` beside the lock makes Pipenv the +/// installer, and Pipenv reads a pylock only when that directory has no +/// `Pipfile.lock`. Its reader (`PylockFile.convert_to_pipenv_lockfile`) +/// keeps a package's version, marker and wheel / sdist hashes and nothing +/// else, so no hosted or vendored `archive` entry survives it: such a lock +/// can't carry a patch. `exists` answers for project-relative paths. +pub fn pipenv_reads_pylock(rel: &str, exists: impl Fn(&str) -> bool) -> bool { + let (dir, name) = match rel.rsplit_once('/') { + Some((dir, name)) => (format!("{dir}/"), name), + None => (String::new(), rel), + }; + is_pep751_lock_name(name) + && exists(&format!("{dir}Pipfile")) + && !exists(&format!("{dir}Pipfile.lock")) } pub fn python_lock_paths(root: &Path) -> std::io::Result> { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index bcd52d3fa..b40817276 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -249,7 +249,9 @@ async fn requirements_pins_target(project_root: &Path, target: Option<(&str, &st /// one exists (a marker alone must not block the requirements wiring): /// 1. `uv.lock` → uv; /// 2. standalone `pylock*.toml` / `*.py.lock` locks containing this package -/// → python-lock; +/// → python-lock — except a pylock beside a `Pipfile`, which Pipenv +/// reads: it loses to `Pipfile.lock` (#1122) and, with no +/// `Pipfile.lock`, refuses `pypi_pipenv_pylock_unsupported` (#912); /// 3. `poetry.lock` → poetry; 4. `pdm.lock` → pdm; 5. `Pipfile.lock` → pipenv; /// 6. lock-less `[tool.uv]`/`[tool.poetry]`/`[tool.pdm]`/`Pipfile` → /// `_no_lockfile` refusal unless requirements.txt exists; @@ -299,6 +301,42 @@ async fn detect_pypi_flavor( .into_iter() .filter(|path| path != "uv.lock") .collect(); + // #912 / #1122: a pylock beside a `Pipfile` belongs to Pipenv, not to a + // PEP 751 installer. With a `Pipfile.lock` Pipenv installs from that + // and ignores the pylock; without one it reads the pylock but drops the + // `archive` entry the wiring writes. Either way the pylock is not the + // file to wire. A higher-ranked tool lock still routes as before. + let (pipenv_pylocks, additional_locks): (Vec, Vec) = + if has_pipfile && matches!(governing, None | Some("Pipfile.lock")) { + additional_locks + .into_iter() + .partition(|path| crate::utils::python_lock::is_pep751_lock_name(path)) + } else { + (Vec::new(), additional_locks) + }; + let pipenv_pylock_matches = if pipenv_pylocks.is_empty() { + false + } else if let Some((name, version)) = target { + super::pypi_lock::contains_target(project_root, &pipenv_pylocks, name, version).await? + } else { + true + }; + if pipenv_pylock_matches { + if governing.is_none() { + return Err(( + "pypi_pipenv_pylock_unsupported", + format!( + "{} is installed by Pipenv (a Pipfile sits beside it and there is no \ + Pipfile.lock), and Pipenv keeps only the version and hashes of a pylock \ + entry, so a vendored wheel would be dropped and the upstream release \ + installed; run `pipenv lock` to write a Pipfile.lock and re-run vendor, \ + or {SETUP_ALTERNATIVE}", + pipenv_pylocks.join(", ") + ), + )); + } + present.extend(pipenv_pylocks.iter().map(String::as_str)); + } let mut warnings = Vec::new(); let matching_additional_lock = if has_uv_lock { false @@ -2738,6 +2776,67 @@ mod tests { } } + /// A pylock `pipenv lock` writes with `[pipenv] use_pylock = true`. + const PIPENV_PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"pipenv\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nwheels = [{ name = \"six-1.16.0-py2.py3-none-any.whl\", url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hashes = { sha256 = \"8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\" } }]\n\n[tool.pipenv]\ngenerated_from = \"Pipfile.lock\"\n"; + + /// #1122: with a `Pipfile` beside both locks, Pipenv installs from + /// `Pipfile.lock` and ignores the pylock, so `Pipfile.lock` is the one + /// wired — the pylock is named as a loud loser, not wired instead. + #[tokio::test] + async fn pipenv_pylock_beside_pipfile_lock_routes_to_pipenv() { + for lock in ["pylock.toml", "pylock.dev.toml"] { + let tmp = tempfile::tempdir().unwrap(); + touch( + tmp.path(), + "Pipfile", + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n", + ) + .await; + touch(tmp.path(), "Pipfile.lock", "{}").await; + touch(tmp.path(), lock, PIPENV_PYLOCK).await; + let (selected, warnings) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) + .await + .unwrap(); + assert_eq!(selected, PypiFlavor::Pipenv, "{lock}: {warnings:?}"); + let loud: Vec<_> = warnings + .iter() + .filter(|w| w.code == "pypi_multiple_lockfiles") + .collect(); + assert_eq!(loud.len(), 1, "{lock}: {warnings:?}"); + assert!( + loud[0].detail.contains("wiring `Pipfile.lock`") && loud[0].detail.contains(lock), + "{lock}: {}", + loud[0].detail + ); + } + } + + /// #912: a pylock-only Pipenv checkout can't be vendored — Pipenv drops + /// the `archive` entry the wiring writes and installs the upstream + /// release — so it refuses with a `pipenv lock` pointer. Without a + /// `Pipfile` the same pylock still routes to the python-lock flavor. + #[tokio::test] + async fn pipenv_pylock_without_pipfile_lock_refuses() { + for lock in ["pylock.toml", "pylock.dev.toml"] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), lock, PIPENV_PYLOCK).await; + let (selected, _) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) + .await + .unwrap(); + assert_eq!(selected, PypiFlavor::PythonLocks, "{lock}"); + + touch(tmp.path(), "Pipfile", "[packages]\nsix = \"==1.16.0\"\n").await; + let (code, detail) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) + .await + .unwrap_err(); + assert_eq!(code, "pypi_pipenv_pylock_unsupported", "{lock}: {detail}"); + assert!( + detail.contains(lock) && detail.contains("pipenv lock"), + "{lock}: {detail}" + ); + } + } + /// One assert per row of the routing table (locks > lock-less markers /// with requirements fallthrough > requirements > pyproject > nothing; /// the python-lock and hatch rows are covered elsewhere). @@ -6712,6 +6811,66 @@ wheels = [ .await; } + /// #1122: a `use_pylock = true` Pipenv project carries `Pipfile`, + /// `Pipfile.lock` and `pylock.toml`; Pipenv installs from `Pipfile.lock`, + /// so vendoring wires `Pipfile.lock` (pipenv flavor), leaves the pylock + /// byte-identical, and reverts cleanly. + #[tokio::test] + async fn pipenv_use_pylock_project_vendors_into_pipfile_lock() { + let fx = e2e_fixture().await; + swap_to_lock_flavor( + &fx, + &[ + ( + "Pipfile", + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n", + ), + ("Pipfile.lock", PIPENV_LOCK_REGISTRY), + ("pylock.toml", PIPENV_PYLOCK), + ], + ) + .await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_six(&fx, &sources, None).await; + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + assert_eq!(entry.flavor.as_deref(), Some("pipenv")); + assert_eq!(entry.wiring[0].file, "Pipfile.lock"); + let wired = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + assert!(wired.contains(&entry.artifact.path), "{wired}"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PIPENV_PYLOCK + ); + assert!( + warnings + .iter() + .any(|w| w.code == "pypi_multiple_lockfiles" && w.detail.contains("pylock.toml")), + "{warnings:?}" + ); + + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(), + PIPENV_LOCK_REGISTRY + ); + } + /// The `vendor_platform_locked` advisory names the file the platform /// pin now lives in, per flavor (the requirements arm is covered by /// `platform_specific_tags_set_platform_locked_and_warn`). diff --git a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs index c87fce1d6..589ec5b7d 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs @@ -106,7 +106,7 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::digest::{sha256_hex, sha256_prefixed}; use crate::utils::python_lock::{ is_script_lock_name, lock_package_collection, package_artifacts, paired_metadata_rel, - script_of_lock, uv_source_location, LockArtifact, UvSource, + pipenv_reads_pylock, script_of_lock, uv_source_location, LockArtifact, UvSource, }; use crate::utils::requirements::url_sha256_fragment; use crate::vendor::lock_inventory::LockIntegrity; @@ -115,6 +115,8 @@ const UV_LOCK: &str = "uv.lock"; const POETRY_LOCK: &str = "poetry.lock"; const PDM_LOCK: &str = "pdm.lock"; const PYPROJECT: &str = "pyproject.toml"; +const PIPFILE: &str = "Pipfile"; +const PIPFILE_LOCK: &str = "Pipfile.lock"; /// pdm.lock formats whose installers drop url/path candidate identity (see /// the module docs; `pdm-native/README.md`). @@ -368,7 +370,7 @@ async fn extract_python_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discov return; } let pairing = if pep751 { - Pairing::LockOnly + pylock_pairing(ctx, file).await } else { load_pairing(ctx, file, out).await }; @@ -611,6 +613,29 @@ async fn load_pairing(ctx: &DiscoverCtx<'_>, lock: &str, out: &mut Discovery) -> } } +/// A PEP 751 lock stands alone — unless Pipenv is its installer (#912): +/// with a `Pipfile` beside it and no `Pipfile.lock`, Pipenv reads the +/// pylock but keeps only each entry's version and hashes, so no Socket +/// `archive` it carries is what gets installed. +async fn pylock_pairing(ctx: &DiscoverCtx<'_>, lock: &str) -> Pairing { + let pipfile = ctx.exists(PIPFILE).await; + let pipfile_lock = ctx.exists(PIPFILE_LOCK).await; + let exists = |rel: &str| match rel { + PIPFILE => pipfile, + PIPFILE_LOCK => pipfile_lock, + _ => false, + }; + if pipenv_reads_pylock(lock, exists) { + unusable( + PIPFILE, + "makes Pipenv the installer, and with no Pipfile.lock Pipenv installs from this \ + pylock keeping only each entry's version and hashes (never its archive)", + ) + } else { + Pairing::LockOnly + } +} + fn unusable(file: &str, why: &str) -> Pairing { Pairing::Unusable { file: file.to_string(), @@ -1281,6 +1306,51 @@ mod tests { } } + /// #912: Pipenv installs a pylock-only project (a `Pipfile` beside the + /// pylock, no `Pipfile.lock`) without the entry's `archive`, so a + /// hosted or vendored pylock reference there is diagnosed, not + /// discovered — vex must not attest the upstream release `pipenv sync` + /// installs. With a `Pipfile.lock` beside it the pylock is read as before. + #[tokio::test] + async fn pylock_read_by_pipenv_is_not_trusted() { + let url = click_url(UUID_A); + let rel = click_vendored(UUID_B); + for (name, artifact, uuid, mode) in [ + ( + "pylock.toml", + ArtifactSource::Url(&url), + UUID_A, + WiringMode::Hosted, + ), + ( + "pylock.dev.toml", + ArtifactSource::Path(&rel), + UUID_B, + WiringMode::Vendored, + ), + ] { + let lock = rewrite_python_lock(&pylock_registry(), "click", "8.1.7", artifact, SHA) + .unwrap() + .unwrap(); + let p = Project::new(); + p.write(name, &lock) + .write("Pipfile", "[packages]\nclick = \"==8.1.7\"\n"); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{name}: {:?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_INVALID && d.detail.contains("Pipenv")), + "{name}: {:?}", + out.diagnostics + ); + + p.write("Pipfile.lock", "{}"); + let out = run(&p).await; + assert_refs(&out, &[(CLICK, uuid, mode)]); + } + } + /// Hand-written `wheels` lists: all-Socket is a ref; a Socket wheel /// beside a registry wheel is ambiguous and diagnosed. #[tokio::test] From 69a3acae9d797a55e51109b5a6697d1fb68b57d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:45:19 +0000 Subject: [PATCH 3/5] Test Pipenv pylock projects with real Pipenv Adds a real-Pipenv 2026 e2e (part of the existing e2e_vex_build pipenv:: legs) for a `[pipenv] use_pylock = true` project: - with Pipfile.lock and pylock.toml, vendored mode wires Pipfile.lock and a fresh `pipenv install --deploy` gets the patched release (#1122); - the pylock-only checkout is refused in hosted and vendored mode and the pylock is left untouched (#912). Documents both cases in the Pipenv compatibility table. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vex_build/pipenv.rs | 133 ++++++++++++++++++ docs/testing/pipenv-compatibility.md | 1 + 2 files changed, 134 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs index 6566226d5..7d0e39360 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs @@ -350,3 +350,136 @@ fn pipenv_every_major_hosted_and_vendored_end_in_manifest_less_vex() { } assert!(failures.is_empty(), "{}", failures.join("\n\n")); } + +const PYLOCK_PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[dev-packages]\n\n[pipenv]\nuse_pylock = true\n"; + +/// #912 / #1122 on a real Pipenv 2026 `use_pylock = true` project, whose +/// `pipenv lock` writes both `Pipfile.lock` and `pylock.toml`: +/// +/// * #1122: vendored wires `Pipfile.lock` (what Pipenv installs from when +/// both exist), leaves the pylock alone, and a fresh +/// `pipenv install --deploy` gets the PATCHED six. +/// * #912: the pylock-only checkout (no `Pipfile.lock`) is refused in both +/// modes — Pipenv would drop the `archive` entry and install the upstream +/// release — and the pylock is left byte-identical. +fn pylock_flow(pipenv: &Pipenv, root: &Path) { + let v = pipenv.version.as_str(); + let what = format!("pipenv {v} use_pylock"); + let proj = root.join("pylock").join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(proj.join("Pipfile"), PYLOCK_PIPFILE).unwrap(); + let python = venv_bin(&pipenv.venv, "python"); + pipenv.project_venv(&proj); + let out = pipenv.run(&proj, &["install", "--python", python.to_str().unwrap()]); + assert_ok(&out, &format!("{what}: pipenv install")); + if !proj.join("pylock.toml").is_file() { + assert_ok( + &pipenv.run(&proj, &["lock"]), + &format!("{what}: pipenv lock"), + ); + } + let pristine_pylock = read(&proj.join("pylock.toml")); + let py = venv_bin(&proj.join(".venv"), "python"); + let (_, pristine, _) = + six_oracle(&py, &proj).unwrap_or_else(|| panic!("{what}: six not importable")); + let patched = [pristine.as_slice(), PATCH_SUFFIX].concat(); + + // #912: the pylock-only checkout, both modes. + for mode in [Mode::Hosted, Mode::Vendored] { + let only = root.join("pylock").join(format!("only-{}", mode.label())); + copy_tree(&proj, &only, &[".venv"]); + std::fs::remove_file(only.join("Pipfile.lock")).unwrap(); + let api = RealApi::start(mode.uuid(), &pristine, &patched); + let (_, env, stderr) = socket_scan(&only, &api, mode.scan_flags(), &pipenv.envs); + let code = match mode { + Mode::Hosted => "redirect_pipenv_pylock_unsupported", + Mode::Vendored => "pypi_pipenv_pylock_unsupported", + }; + let ok = env.to_string().contains(code) + && read(&only.join("pylock.toml")) == pristine_pylock + && !only.join("Pipfile.lock").exists(); + record( + "pipenv", + v, + &format!("pylock-only/{}", mode.label()), + "refused", + if ok { "pass" } else { "FAIL" }, + ); + assert!( + ok, + "{what} pylock-only {}: expected {code}, pylock untouched: {env}\n{stderr}", + mode.label() + ); + } + + // #1122: both locks, vendored. + let mode = Mode::Vendored; + let api = RealApi::start(mode.uuid(), &pristine, &patched); + let (code, env, stderr) = socket_scan(&proj, &api, mode.scan_flags(), &pipenv.envs); + assert_eq!(code, Some(0), "{what}: vendored scan: {env}\n{stderr}"); + let lock = String::from_utf8(read(&proj.join("Pipfile.lock"))).unwrap(); + assert!( + lock.contains(&format!(".socket/vendor/pypi/{}/", mode.uuid())), + "{what}: Pipfile.lock must point at the vendored wheel: {lock}" + ); + assert_eq!( + read(&proj.join("pylock.toml")), + pristine_pylock, + "{what}: the pylock Pipenv ignores is left alone" + ); + let fresh = root.join("pylock").join("fresh"); + copy_tree(&proj, &fresh, &[".venv"]); + pipenv.project_venv(&fresh); + let out = pipenv.run( + &fresh, + &["install", "--deploy", "--python", python.to_str().unwrap()], + ); + assert_ok(&out, &format!("{what}: fresh `pipenv install --deploy`")); + let (_, bytes, is_patched) = six_oracle(&venv_bin(&fresh.join(".venv"), "python"), &fresh) + .unwrap_or_else(|| panic!("{what}: six not importable in the fresh checkout")); + let ok = is_patched && bytes == patched; + record( + "pipenv", + v, + "use_pylock/vendored", + "install-patched", + if ok { "pass" } else { "FAIL" }, + ); + assert!(ok, "{what}: the fresh install must carry the PATCHED six"); +} + +#[test] +#[ignore = "real Pipenv releases + PyPI (network). Run with --ignored."] +fn pipenv_pylock_projects_wire_what_pipenv_installs() { + let Some(uv) = find_uv() else { + return skip_or_fail(REQUIRED, "uv is not installed"); + }; + let mut failures = Vec::new(); + // `[pipenv] use_pylock` is a Pipenv 2026 setting. + for version in versions(VERSIONS_VAR, VERSIONS) + .into_iter() + .filter(|v| year(v) >= 2026) + { + let scratch = tempfile::tempdir().unwrap(); + let pipenv = match Pipenv::bootstrap(&uv, &version, scratch.path()) { + Ok(p) => p, + Err(e) => { + record("pipenv", &version, "use_pylock", "bootstrap", "SKIP"); + skip_or_fail(REQUIRED, &format!("pipenv {version} bootstrap: {e}")); + continue; + } + }; + let root = scratch.path().join("run"); + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pylock_flow(&pipenv, &root))); + if let Err(e) = result { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_default(); + failures.push(format!("pipenv {version} use_pylock: {msg}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} diff --git a/docs/testing/pipenv-compatibility.md b/docs/testing/pipenv-compatibility.md index d17693a47..ac0f16fcd 100644 --- a/docs/testing/pipenv-compatibility.md +++ b/docs/testing/pipenv-compatibility.md @@ -19,6 +19,7 @@ requirements.txt lanes of the same ecosystem. |-------|--------|----------|-------| | `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`/`index` kept as Pipenv wrote them and `version` 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). Settings come from the project's `.env` (or `PIPENV_DOTENV_LOCATION`, unless `PIPENV_DONT_LOAD_ENV`) and the process environment using the concrete current, process-cached, 2018 shell, and 2020 shell profiles described below. With a `.venv` directory and an existing WORKON_HOME venv, each profile retains its native in-project choice: Pipenv 2026.2+ prefers the WORKON_HOME venv when nothing is set, 2023.11.14+ uses it when the project is explicitly not in-project, and older releases use `.venv` either way. | | `Pipfile.lock`, `pipfile-spec` < 6 (Pipenv 0–6) | Refused (`redirect_pipenv_skipped`), lock untouched. | Refused (`pypi_pipenv_spec_unsupported`). | Works. | +| PEP 751 `pylock.toml` / `pylock..toml` beside the Pipfile (Pipenv 2026, `[pipenv] use_pylock = true`) | With a `Pipfile.lock`, Pipenv installs from it: both locks are redirected. With no `Pipfile.lock`, Pipenv installs from the pylock but keeps only each entry's version and hashes (never an `archive`), so the pin is refused (`redirect_pipenv_pylock_unsupported`) and nothing is written; run `pipenv lock`. | With a `Pipfile.lock`, it is the file wired; the pylock is named in `pypi_multiple_lockfiles`. With no `Pipfile.lock`, refused (`pypi_pipenv_pylock_unsupported`), and `vex` does not trust a Socket reference in that pylock. | Works. | | Lock-only checkout (nothing installed) | Discovered from the lock and redirected. | Discovered from the lock; the patched wheel or source distribution is downloaded and verified from the service without a local install. | Nothing to patch (no installed distribution); the lock's pins are listed as lockfile-only packages. | Pipenv command timing and dotenv behavior changed across supported releases. Current commands resolve complete python-dotenv records with preceding bindings ahead of process variables, then apply their active-environment settings. Older commands can cache placement before loading dotenv, so discovery also retains the process-only result. The 2018 shell instead resolves dotenv references from the complete final mapping with process variables first; its Project settings were cached before loading, and it marks itself active before final placement. The 2020 shell uses modern parsing but still caches Project settings such as `IGNORE_VIRTUALENVS` and `VENV_IN_PROJECT`; it reads the active prefix before setting `PIPENV_ACTIVE`. These two cached profiles do not support custom venv names. Discovery combines only project-owned environments from these observed profiles, without executing Pipenv or searching unrelated venvs. From d2c42a95122f67ff41ab4d2366deeede12dc6a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:28:59 +0000 Subject: [PATCH 4/5] Let a governing tool lock outrank a stray Pipfile A Pipfile left beside a governing uv.lock, poetry.lock or pdm.lock does not make Pipenv the installer, but the hosted pylock rewriter and VEX discovery still treated the pylock as Pipenv's. The hosted refusal then marked the patch refused for every Python lock, so a successful uv.lock pin was reported as not redirected and dropped from VEX. pipenv_reads_pylock now applies the same tool-lock precedence the vendored router already used, so all three call sites agree. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/pipenv_pylock_tests.rs | 47 ++++++++++++++++ .../src/utils/python_lock.rs | 18 ++++--- .../src/vex/discover/pypi_locks.rs | 53 +++++++++++++++---- 3 files changed, 100 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs b/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs index e28665eb1..d0e4a0629 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs @@ -127,3 +127,50 @@ fn pylock_without_a_pipenv_consumer_or_beside_pipfile_lock_still_pins() { .iter() .all(|w| w.code != "redirect_pipenv_pylock_unsupported")); } + +/// A leftover `Pipfile` beside a governing uv / Poetry / PDM lock does not +/// make Pipenv the installer: the pylock is pinned as before, and no Pipenv +/// refusal vetoes the sibling lock's confirmation. +#[test] +fn pylock_beside_a_governing_tool_lock_is_not_pipenvs() { + let uv_lock = format!( + "version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/packages/d9/5a/{WHEEL}\", hash = \"sha256:{HEX}\" }}]\n" + ); + for (lock, text) in [ + ("uv.lock", uv_lock), + ("poetry.lock", String::new()), + ("pdm.lock", String::new()), + ] { + let result = rewrite(&files(&[ + ("Pipfile", PIPFILE.into()), + (lock, text), + ("pylock.toml", pylock()), + ])); + assert!( + result + .warnings + .iter() + .all(|w| w.code != "redirect_pipenv_pylock_unsupported"), + "{lock}: {:?}", + result.warnings + ); + // Only the real uv.lock fixture pins cleanly; the empty Poetry / + // PDM stubs are refused by their own rewriters. + if lock != "uv.lock" { + continue; + } + assert!( + !result.refused_python_lock_uuids.contains(UUID), + "{lock}: {:?}", + result.warnings + ); + assert!( + result + .files + .get("pylock.toml") + .is_some_and(|t| t.contains(WHEEL) && t.contains("archive")), + "{lock}: {:?}", + result.warnings + ); + } +} diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 4940ba6b1..bc7d01d3e 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -185,19 +185,23 @@ pub fn is_pep751_lock_name(name: &str) -> bool { /// #912 / #1122: whether Pipenv installs from the PEP 751 lock `rel` (a /// project-relative path). A `Pipfile` beside the lock makes Pipenv the -/// installer, and Pipenv reads a pylock only when that directory has no -/// `Pipfile.lock`. Its reader (`PylockFile.convert_to_pipenv_lockfile`) -/// keeps a package's version, marker and wheel / sdist hashes and nothing -/// else, so no hosted or vendored `archive` entry survives it: such a lock -/// can't carry a patch. `exists` answers for project-relative paths. +/// installer — unless a higher-ranked tool lock (`uv.lock`, `poetry.lock`, +/// `pdm.lock`) governs the directory, leaving the Pipfile a leftover — and +/// Pipenv reads a pylock only when that directory has no `Pipfile.lock`. +/// Its reader (`PylockFile.convert_to_pipenv_lockfile`) keeps a package's +/// version, marker and wheel / sdist hashes and nothing else, so no hosted +/// or vendored `archive` entry survives it: such a lock can't carry a +/// patch. `exists` answers for project-relative paths. pub fn pipenv_reads_pylock(rel: &str, exists: impl Fn(&str) -> bool) -> bool { let (dir, name) = match rel.rsplit_once('/') { Some((dir, name)) => (format!("{dir}/"), name), None => (String::new(), rel), }; + let in_dir = |file: &str| exists(&format!("{dir}{file}")); is_pep751_lock_name(name) - && exists(&format!("{dir}Pipfile")) - && !exists(&format!("{dir}Pipfile.lock")) + && in_dir("Pipfile") + && !crate::formats::governing_locks::pypi_tool_lock_shadowed("Pipfile.lock", in_dir) + && !in_dir("Pipfile.lock") } pub fn python_lock_paths(root: &Path) -> std::io::Result> { diff --git a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs index 589ec5b7d..f6694454d 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_locks.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_locks.rs @@ -614,18 +614,18 @@ async fn load_pairing(ctx: &DiscoverCtx<'_>, lock: &str, out: &mut Discovery) -> } /// A PEP 751 lock stands alone — unless Pipenv is its installer (#912): -/// with a `Pipfile` beside it and no `Pipfile.lock`, Pipenv reads the -/// pylock but keeps only each entry's version and hashes, so no Socket -/// `archive` it carries is what gets installed. +/// with a `Pipfile` beside it, no governing uv / Poetry / PDM lock and no +/// `Pipfile.lock`, Pipenv reads the pylock but keeps only each entry's +/// version and hashes, so no Socket `archive` it carries is what gets +/// installed. async fn pylock_pairing(ctx: &DiscoverCtx<'_>, lock: &str) -> Pairing { - let pipfile = ctx.exists(PIPFILE).await; - let pipfile_lock = ctx.exists(PIPFILE_LOCK).await; - let exists = |rel: &str| match rel { - PIPFILE => pipfile, - PIPFILE_LOCK => pipfile_lock, - _ => false, - }; - if pipenv_reads_pylock(lock, exists) { + let mut present = Vec::new(); + for file in [PIPFILE, PIPFILE_LOCK, UV_LOCK, POETRY_LOCK, PDM_LOCK] { + if ctx.exists(file).await { + present.push(file); + } + } + if pipenv_reads_pylock(lock, |rel| present.contains(&rel)) { unusable( PIPFILE, "makes Pipenv the installer, and with no Pipfile.lock Pipenv installs from this \ @@ -1351,6 +1351,37 @@ mod tests { } } + /// A leftover `Pipfile` beside a governing Poetry / PDM lock does not + /// make Pipenv the pylock's installer: the reference is discovered. + #[tokio::test] + async fn pylock_beside_a_governing_tool_lock_is_trusted() { + let url = click_url(UUID_A); + let lock = rewrite_python_lock( + &pylock_registry(), + "click", + "8.1.7", + ArtifactSource::Url(&url), + SHA, + ) + .unwrap() + .unwrap(); + for tool_lock in ["poetry.lock", "pdm.lock"] { + let p = Project::new(); + p.write("pylock.toml", &lock) + .write("Pipfile", "[packages]\n") + .write(tool_lock, ""); + let out = run(&p).await; + assert!( + out.refs + .iter() + .any(|r| r.source_file.ends_with("pylock.toml")), + "{tool_lock}: {:?} {:?}", + out.refs, + out.diagnostics + ); + } + } + /// Hand-written `wheels` lists: all-Socket is a ref; a Socket wheel /// beside a registry wheel is ambiguous and diagnosed. #[tokio::test] From 2cb93163e2f5295ef3ac7dfea6022f88f4177358 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:07:52 +0000 Subject: [PATCH 5/5] Serialize real-PM tool venv bootstraps bootstrap_tool checks for a healthy tool venv and then creates it, with no lock in between. The Pipenv e2e suite now has two tests, and libtest runs them in parallel. On a cold tools cache both miss the probe and race `uv venv` on the same pipenv- directory, so one fails with "a virtual environment already exists". This reproduced in CI and 5/5 times locally from an empty tools root. A process-wide lock around the check-and-create fixes it; warm-cache runs only pay for a --version probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzAvnBX6HySvR6zXsFtdy7 --- crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs index 6c8482cce..4fd2e0f56 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs @@ -268,7 +268,9 @@ pub fn make_venv(uv: &Path, python: &str, venv: &Path, pins: &[&str]) -> Result< } /// `/` made by [`make_venv`] unless `probe` already runs -/// there (bootstraps are reused across runs). +/// there (bootstraps are reused across runs). Serialized: tests in one +/// binary run in parallel, and two of them bootstrapping the same venv +/// race `uv venv` into "a virtual environment already exists". pub fn bootstrap_tool( uv: &Path, name: &str, @@ -276,6 +278,8 @@ pub fn bootstrap_tool( pins: &[&str], probe: &str, ) -> Result { + static BOOTSTRAP: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = BOOTSTRAP.lock().unwrap_or_else(|e| e.into_inner()); let venv = tools_root().join(name); let exe = venv_bin(&venv, probe); let healthy = |exe: &Path| {