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/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| { 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..d0e4a0629 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs @@ -0,0 +1,176 @@ +//! #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")); +} + +/// 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 70e848dc4..bc7d01d3e 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -175,10 +175,33 @@ 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 — 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) + && 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/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 7193de2c3..ce0022eb1 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -254,7 +254,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; @@ -304,6 +306,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 @@ -2743,6 +2781,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}" + ); + } + } + /// #1120: a `requirements.txt` exported beside the governing lock in /// UTF-16 (`uv export > requirements.txt` in Windows PowerShell 5.1) /// is installed by pip and uv like its UTF-8 twin, so it is a loud @@ -6759,6 +6858,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..f6694454d 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, 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 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 \ + 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,82 @@ 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)]); + } + } + + /// 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] 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.