diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a92c2ae..c0c4f38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,9 @@ limits, and required install commands. - Python rewrites preserve supported markers, groups, extras, source metadata, and integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled consistently with each installer's supported behavior. +- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry + refuses the patch project-wide instead of half-redirecting a sibling + `requirements.txt` (#333). - Vendoring reuses valid committed artifacts during service outages. Updates do not build from a previous patch's modified bytes. Verified service artifacts keep their identity; integrity failures do not fall through to a local rebuild. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d6031e78..0b62d774 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3531,6 +3531,7 @@ mod tests { "poetry.lock", "pdm.lock", "Pipfile.lock", + "Pipfile", "pyproject.toml", "hatch.toml", "Cargo.toml", diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index bf2e00fd..ea25f497 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -14,6 +14,9 @@ //! installer probe would otherwise need a real Pipenv 7–11 on PATH for; //! * a stale `Pipfile.lock` that does not pin the package does not veto //! the sibling `requirements.txt` redirect; +//! * a conflicting entry in a live `Pipfile.lock` (a `Pipfile` beside it) +//! vetoes the sibling `requirements.txt` redirect, while the same +//! conflict in an abandoned lock (no `Pipfile`) does not (#333); //! * a venv still holding the UPSTREAM release is reported stale and kept //! out of the same-run attestation. //! @@ -473,6 +476,70 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale); } +/// The lock entry repointed at the user's own wheel: a `file` source that +/// is not Socket's, which the Pipenv planner refuses as a conflict. +fn lock_with_user_file_source() -> String { + LOCK.replace( + "\"version\": \"==1.26.18\"", + "\"file\": \"wheels/urllib3-1.26.18-py2.py3-none-any.whl\"", + ) +} + +/// #333: a conflicting entry in a LIVE Pipfile.lock (a Pipfile beside it) +/// means Pipenv never installs the patch, so the patch is refused for the +/// whole project. The hosted scan must therefore see the Pipfile: the +/// sibling requirements.txt stays untouched instead of being +/// half-redirected. +#[tokio::test] +#[serial] +async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { + let _major = MajorGuard::set("2026"); + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let lock = lock_with_user_file_source(); + std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap(); + const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); + + run(hosted_args(tmp.path(), server.uri(), None)).await; + assert_eq!( + read(&tmp.path().join("requirements.txt")), + REQS, + "a live Pipfile.lock conflict must veto the sibling requirements.txt" + ); + assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock); + assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE); +} + +/// The same conflict in an ABANDONED lock (no Pipfile beside it) says +/// nothing about the project's install files: the sibling requirements.txt +/// is still redirected. +#[tokio::test] +#[serial] +async fn abandoned_pipfile_lock_conflict_does_not_veto_the_requirements_redirect() { + let _major = MajorGuard::set("2026"); + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + std::fs::remove_file(tmp.path().join("Pipfile")).unwrap(); + let lock = lock_with_user_file_source(); + std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap(); + const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri(), None)).await; + assert_eq!(code, 0); + let requirements = read(&tmp.path().join("requirements.txt")); + assert!( + requirements.contains(HOSTED_URL), + "an abandoned lock must not veto requirements.txt: {requirements}" + ); + assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock); +} + #[tokio::test] #[serial] async fn warm_venv_with_the_upstream_release_is_not_attested() { diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index a0c703ee..1d27fc5c 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -101,6 +101,10 @@ const REGISTRY: &[FormatFile] = &[ row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + // Never edited — its presence tells the Pipenv planner a Pipfile.lock is + // live (a conflict there vetoes the sibling Python rewriters) rather + // than abandoned. + row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY), row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), row("hatch.toml", "pypi", HOSTED | PROBE), // ── cargo ── @@ -217,6 +221,7 @@ mod tests { assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); assert_eq!(hosted_file_ecosystem("build.gradle"), None); + assert_eq!(hosted_file_ecosystem("Pipfile"), None); assert_eq!(hosted_file_ecosystem("package.json"), None); assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget")); } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 2d9173b9..49efe2bd 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -377,6 +377,22 @@ fn rush_repo(view: &ProjectView<'_>) -> bool { } } +/// Whether `rel` is a [`PRESENCE_ONLY`](crate::formats::registry::PRESENCE_ONLY) +/// row the in-memory host lists without usable content (a symbolic link, +/// an oversize or presence-only entry). Its planners only ask whether it +/// exists — the Pipenv planner tells a live `Pipfile.lock` from an +/// abandoned one by the `Pipfile` beside it — so it is recorded as present +/// (empty) rather than dropped. Disk reads such a file through any link. +fn presence_only_present(view: &ProjectView<'_>, rel: &str) -> bool { + let ProjectView::Memory(project) = view else { + return false; + }; + project.contains(rel) + && crate::formats::registry::registry() + .iter() + .any(|f| f.path == rel && f.has(crate::formats::registry::PRESENCE_ONLY)) +} + /// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the /// Cargo workspace members (when a cargo candidate meets a root /// `Cargo.toml`), the Python locks and their scripts, and the Rush locks. @@ -399,7 +415,9 @@ pub async fn read_candidate_files( } continue; } - out.read(view, unreadable, name).await; + if !out.read(view, unreadable, name).await && presence_only_present(view, name) { + out.files.insert((*name).to_string(), String::new()); + } } // Cargo workspace members (and in-root path dependencies) declare @@ -1556,6 +1574,44 @@ mod tests { assert!(read.unreadable_reads.is_empty()); } + /// #333: the Pipenv planner keys a live lock on the `Pipfile` beside + /// it, so the candidate reads must carry it — read from disk, and kept + /// as present in memory even when the host has no content for it. + #[tokio::test] + async fn the_pipfile_is_read_for_its_presence() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Pipfile"), "[packages]\n").unwrap(); + std::fs::write(tmp.path().join("Pipfile.lock"), "{}").unwrap(); + let read = + read_candidate_files(&ProjectView::Disk(tmp.path()), &BTreeSet::new(), &[]).await; + assert_eq!( + read.files.get("Pipfile").map(String::as_str), + Some("[packages]\n") + ); + + for entry in [MemoryEntry::Symlink, MemoryEntry::Present] { + let mut p = MemoryProject::new(); + p.insert_text("Pipfile.lock", "{}"); + p.insert("Pipfile", entry.clone()); + let unreadable = match entry { + MemoryEntry::Present => BTreeSet::from(["Pipfile".to_string()]), + _ => BTreeSet::new(), + }; + let read = read_candidate_files(&ProjectView::Memory(&p), &unreadable, &[]).await; + assert_eq!( + read.files.get("Pipfile").map(String::as_str), + Some(""), + "{entry:?}" + ); + } + + // Absent stays absent: a lone Pipfile.lock is abandoned. + let mut p = MemoryProject::new(); + p.insert_text("Pipfile.lock", "{}"); + let read = read_candidate_files(&ProjectView::Memory(&p), &BTreeSet::new(), &[]).await; + assert!(!read.files.contains_key("Pipfile")); + } + #[test] fn file_ecosystems_cover_the_rewrite_targets() { assert_eq!(file_ecosystem("package-lock.json"), Some("npm")); @@ -1566,6 +1622,7 @@ mod tests { assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi")); assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); assert_eq!(file_ecosystem("build.gradle"), None); + assert_eq!(file_ecosystem("Pipfile"), None); } }