Skip to content

Commit c7af4df

Browse files
Fix hosted scan not reading the Pipfile (#333) (#425)
* Start fix for #333 Assisted-by: Claude Code:claude-opus-5-5 * Hosted scan: read the Pipfile beside Pipfile.lock A hosted scan never read the project's Pipfile, so every Pipenv project looked like it had an abandoned Pipfile.lock. When that lock pinned the package to the user's own wheel, another version or a VCS source, the scan still redirected a sibling requirements.txt, reported success, and warned that there was no Pipfile. Pipenv kept installing the unpatched package. The format registry now lists Pipfile as a hosted, presence-only file. Both the disk and the in-memory hosted engines read it, and the in-memory engine still counts it as present when the host has no usable content for it (a symlink or an oversize file). A conflict in a live Pipfile.lock now refuses the patch for the whole project. Fixes #333 Assisted-by: Claude Code:claude-opus-5-5 * Note the Pipenv hosted fix in the changelog Assisted-by: Claude Code:claude-opus-5-5 * Remove a stray patch backup file Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 641eaac commit c7af4df

5 files changed

Lines changed: 134 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ limits, and required install commands.
148148
- Python rewrites preserve supported markers, groups, extras, source metadata, and
149149
integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled
150150
consistently with each installer's supported behavior.
151+
- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry
152+
refuses the patch project-wide instead of half-redirecting a sibling
153+
`requirements.txt` (#333).
151154
- Vendoring reuses valid committed artifacts during service outages. Updates do
152155
not build from a previous patch's modified bytes. Verified service artifacts
153156
keep their identity; integrity failures do not fall through to a local rebuild.

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3596,6 +3596,7 @@ mod tests {
35963596
"poetry.lock",
35973597
"pdm.lock",
35983598
"Pipfile.lock",
3599+
"Pipfile",
35993600
"pyproject.toml",
36003601
"hatch.toml",
36013602
"Cargo.toml",

‎crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
//! installer probe would otherwise need a real Pipenv 7–11 on PATH for;
1515
//! * a stale `Pipfile.lock` that does not pin the package does not veto
1616
//! the sibling `requirements.txt` redirect;
17+
//! * a conflicting entry in a live `Pipfile.lock` (a `Pipfile` beside it)
18+
//! vetoes the sibling `requirements.txt` redirect, while the same
19+
//! conflict in an abandoned lock (no `Pipfile`) does not (#333);
1720
//! * a venv still holding the UPSTREAM release is reported stale and kept
1821
//! out of the same-run attestation.
1922
//!
@@ -473,6 +476,70 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() {
473476
assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale);
474477
}
475478

479+
/// The lock entry repointed at the user's own wheel: a `file` source that
480+
/// is not Socket's, which the Pipenv planner refuses as a conflict.
481+
fn lock_with_user_file_source() -> String {
482+
LOCK.replace(
483+
"\"version\": \"==1.26.18\"",
484+
"\"file\": \"wheels/urllib3-1.26.18-py2.py3-none-any.whl\"",
485+
)
486+
}
487+
488+
/// #333: a conflicting entry in a LIVE Pipfile.lock (a Pipfile beside it)
489+
/// means Pipenv never installs the patch, so the patch is refused for the
490+
/// whole project. The hosted scan must therefore see the Pipfile: the
491+
/// sibling requirements.txt stays untouched instead of being
492+
/// half-redirected.
493+
#[tokio::test]
494+
#[serial]
495+
async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() {
496+
let _major = MajorGuard::set("2026");
497+
let server = MockServer::start().await;
498+
mock_api(&server).await;
499+
let tmp = tempfile::tempdir().unwrap();
500+
write_project(tmp.path());
501+
let lock = lock_with_user_file_source();
502+
std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap();
503+
const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n";
504+
std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap();
505+
506+
run(hosted_args(tmp.path(), server.uri(), None)).await;
507+
assert_eq!(
508+
read(&tmp.path().join("requirements.txt")),
509+
REQS,
510+
"a live Pipfile.lock conflict must veto the sibling requirements.txt"
511+
);
512+
assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock);
513+
assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE);
514+
}
515+
516+
/// The same conflict in an ABANDONED lock (no Pipfile beside it) says
517+
/// nothing about the project's install files: the sibling requirements.txt
518+
/// is still redirected.
519+
#[tokio::test]
520+
#[serial]
521+
async fn abandoned_pipfile_lock_conflict_does_not_veto_the_requirements_redirect() {
522+
let _major = MajorGuard::set("2026");
523+
let server = MockServer::start().await;
524+
mock_api(&server).await;
525+
let tmp = tempfile::tempdir().unwrap();
526+
write_project(tmp.path());
527+
std::fs::remove_file(tmp.path().join("Pipfile")).unwrap();
528+
let lock = lock_with_user_file_source();
529+
std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap();
530+
const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n";
531+
std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap();
532+
533+
let code = run(hosted_args(tmp.path(), server.uri(), None)).await;
534+
assert_eq!(code, 0);
535+
let requirements = read(&tmp.path().join("requirements.txt"));
536+
assert!(
537+
requirements.contains(HOSTED_URL),
538+
"an abandoned lock must not veto requirements.txt: {requirements}"
539+
);
540+
assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock);
541+
}
542+
476543
#[tokio::test]
477544
#[serial]
478545
async fn warm_venv_with_the_upstream_release_is_not_attested() {

‎crates/socket-patch-core/src/formats/registry.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ const REGISTRY: &[FormatFile] = &[
101101
row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
102102
row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
103103
row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
104+
// Never edited — its presence tells the Pipenv planner a Pipfile.lock is
105+
// live (a conflict there vetoes the sibling Python rewriters) rather
106+
// than abandoned.
107+
row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY),
104108
row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE),
105109
row("hatch.toml", "pypi", HOSTED | PROBE),
106110
// ── cargo ──
@@ -225,6 +229,7 @@ mod tests {
225229
assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo"));
226230
assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven"));
227231
assert_eq!(hosted_file_ecosystem("build.gradle"), None);
232+
assert_eq!(hosted_file_ecosystem("Pipfile"), None);
228233
assert_eq!(hosted_file_ecosystem("package.json"), None);
229234
assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget"));
230235
}

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,22 @@ fn rush_repo(view: &ProjectView<'_>) -> bool {
377377
}
378378
}
379379

380+
/// Whether `rel` is a [`PRESENCE_ONLY`](crate::formats::registry::PRESENCE_ONLY)
381+
/// row the in-memory host lists without usable content (a symbolic link,
382+
/// an oversize or presence-only entry). Its planners only ask whether it
383+
/// exists — the Pipenv planner tells a live `Pipfile.lock` from an
384+
/// abandoned one by the `Pipfile` beside it — so it is recorded as present
385+
/// (empty) rather than dropped. Disk reads such a file through any link.
386+
fn presence_only_present(view: &ProjectView<'_>, rel: &str) -> bool {
387+
let ProjectView::Memory(project) = view else {
388+
return false;
389+
};
390+
project.contains(rel)
391+
&& crate::formats::registry::registry()
392+
.iter()
393+
.any(|f| f.path == rel && f.has(crate::formats::registry::PRESENCE_ONLY))
394+
}
395+
380396
/// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the
381397
/// Cargo workspace members (when a cargo candidate meets a root
382398
/// `Cargo.toml`), the Python locks and their scripts, and the Rush locks.
@@ -399,7 +415,9 @@ pub async fn read_candidate_files(
399415
}
400416
continue;
401417
}
402-
out.read(view, unreadable, name).await;
418+
if !out.read(view, unreadable, name).await && presence_only_present(view, name) {
419+
out.files.insert((*name).to_string(), String::new());
420+
}
403421
}
404422

405423
// Cargo workspace members (and in-root path dependencies) declare
@@ -1559,6 +1577,44 @@ mod tests {
15591577
assert!(read.unreadable_reads.is_empty());
15601578
}
15611579

1580+
/// #333: the Pipenv planner keys a live lock on the `Pipfile` beside
1581+
/// it, so the candidate reads must carry it — read from disk, and kept
1582+
/// as present in memory even when the host has no content for it.
1583+
#[tokio::test]
1584+
async fn the_pipfile_is_read_for_its_presence() {
1585+
let tmp = tempfile::tempdir().unwrap();
1586+
std::fs::write(tmp.path().join("Pipfile"), "[packages]\n").unwrap();
1587+
std::fs::write(tmp.path().join("Pipfile.lock"), "{}").unwrap();
1588+
let read =
1589+
read_candidate_files(&ProjectView::Disk(tmp.path()), &BTreeSet::new(), &[]).await;
1590+
assert_eq!(
1591+
read.files.get("Pipfile").map(String::as_str),
1592+
Some("[packages]\n")
1593+
);
1594+
1595+
for entry in [MemoryEntry::Symlink, MemoryEntry::Present] {
1596+
let mut p = MemoryProject::new();
1597+
p.insert_text("Pipfile.lock", "{}");
1598+
p.insert("Pipfile", entry.clone());
1599+
let unreadable = match entry {
1600+
MemoryEntry::Present => BTreeSet::from(["Pipfile".to_string()]),
1601+
_ => BTreeSet::new(),
1602+
};
1603+
let read = read_candidate_files(&ProjectView::Memory(&p), &unreadable, &[]).await;
1604+
assert_eq!(
1605+
read.files.get("Pipfile").map(String::as_str),
1606+
Some(""),
1607+
"{entry:?}"
1608+
);
1609+
}
1610+
1611+
// Absent stays absent: a lone Pipfile.lock is abandoned.
1612+
let mut p = MemoryProject::new();
1613+
p.insert_text("Pipfile.lock", "{}");
1614+
let read = read_candidate_files(&ProjectView::Memory(&p), &BTreeSet::new(), &[]).await;
1615+
assert!(!read.files.contains_key("Pipfile"));
1616+
}
1617+
15621618
#[test]
15631619
fn file_ecosystems_cover_the_rewrite_targets() {
15641620
assert_eq!(file_ecosystem("package-lock.json"), Some("npm"));
@@ -1569,6 +1625,7 @@ mod tests {
15691625
assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi"));
15701626
assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo"));
15711627
assert_eq!(file_ecosystem("build.gradle"), None);
1628+
assert_eq!(file_ecosystem("Pipfile"), None);
15721629
}
15731630
}
15741631

0 commit comments

Comments
 (0)