Skip to content

Commit b1540f8

Browse files
Fix PyPI agent apply patching only one installed copy (#529, #501) (#538)
* Start fix for #529, #501 Assisted-by: Claude Code:claude-opus-5-5 * Test PyPI apply patches every installed copy Regression tests for #529 (Pipenv WORKON_HOME venv beside ./.venv) and #501 (user site beside a system dist-packages in global scope): agent apply must patch both copies, vex must then attest, and rollback must restore both. Assisted-by: Claude Code:claude-opus-5-5 * Patch every PyPI copy in agent apply The Python crawler can find one release in several site-packages dirs, but apply patched only the first. The copy the interpreter actually imports could stay vulnerable while vex attested it. Apply now patches every distinct copy, like gem and npm; paths that alias one directory through a symlink count once. Fixes #529, #501 Assisted-by: Claude Code:claude-opus-5-5 * Assert per-copy applied events in multi-copy test The apply JSON envelope reports per-copy outcomes as events, not results; count the applied events for the patched release. Assisted-by: Claude Code:claude-opus-5-5 * Prefetch blobs for each copy's PyPI variant Two environments can hold different wheels of one release. Apply gates each variant per copy, but the blob prefetch gated it against the first copy only, so a locally modified file in the second copy never got its full patched blob and its apply then failed. The prefetch now probes each variant on the copies it is applied to. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffe6897 commit b1540f8

4 files changed

Lines changed: 470 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ limits, and required install commands.
102102

103103
### Fixed
104104

105+
- Agent-mode PyPI `apply` patches every installed copy of a release, not just
106+
the first one found. A Pipenv project with both a WORKON_HOME venv and a
107+
`./.venv`, or a global install with the same release in the user site and a
108+
system dir, no longer keeps the copy Python imports unpatched while `vex`
109+
attests it (#529, #501).
105110
- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb`
106111
twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer
107112
leads to an edit of an ignored `Gemfile` that reports success and attests an

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,11 @@ the model is **not uniform** today:
502502
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's
503503
flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy,
504504
mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`,
505-
`vex`) use the highest-precedence copy.
505+
`vex`) use the highest-precedence copy. PyPI follows the same rule: when the crawler resolves
506+
one release in several site-packages dirs (a Pipenv WORKON_HOME venv beside an auto-detected
507+
`./.venv`, or the user site beside a system dir in global scope), agent `apply` patches **every**
508+
copy, one summary event per copy, because any of them may be the one the interpreter imports.
509+
Paths that resolve to the same directory (a symlinked site-packages) count as one copy.
506510

507511
*Copy classes (additive to the multi-copy vocabulary):* a copy under a **bundle-path store**
508512
(config/env/default root) is PRIMARY — a variant mismatch or write failure there fails the run,

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 161 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,11 @@ fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String {
207207
/// duplicates of one `name@version`, the apply loop patches each of them,
208208
/// and copies drift independently — a pristine (or already-patched) root
209209
/// copy says nothing about a locally-modified nested duplicate, whose
210-
/// mismatched files still need their afterHash blobs. The variant gate,
211-
/// by contrast, mirrors the apply loop's representative check against the
212-
/// FIRST copy (release-variant ecosystems install one directory per
213-
/// `package@version`).
210+
/// mismatched files still need their afterHash blobs. The variant gate
211+
/// mirrors the apply loop's representative check PER COPY for gem and
212+
/// PyPI (which patch every copy, and two envs can hold different wheels
213+
/// of one release), and against the FIRST copy for Maven: a variant's
214+
/// files are probed only on the copies it is attempted on.
214215
///
215216
/// Only a mismatched file whose afterHash blob is NOT staged can queue a
216217
/// fetch, so the probe first decides that with metadata probes alone and
@@ -263,25 +264,45 @@ async fn mismatch_blob_gaps(
263264
|| records
264265
.first()
265266
.is_some_and(|(key, _)| key.as_str() != stripped));
267+
// The copies the apply loop gates per copy: gem and PyPI patch
268+
// every copy, each against its own representative check; Maven
269+
// gates (and patches) only the first.
270+
let gate_copies: &[PathBuf] = if matches!(
271+
Ecosystem::from_purl(purl),
272+
Some(Ecosystem::Gem | Ecosystem::Pypi)
273+
) {
274+
pkg_paths.as_slice()
275+
} else {
276+
std::slice::from_ref(first_path)
277+
};
266278
for (_, record) in records {
267279
if !can_queue(record) {
268280
continue;
269281
}
270-
if gated {
271-
if let Some((file_name, file_info)) = representative_file(&record.files) {
272-
let status = verify_file_patch(first_path, file_name, file_info)
273-
.await
274-
.status;
275-
if !variant_matches_installed(Some(&status)) {
276-
continue;
282+
// Copies this variant is attempted on: a copy whose installed
283+
// distribution is another variant (two envs can hold different
284+
// wheels of one release) is skipped there by the apply loop.
285+
let probe_copies: Vec<&PathBuf> = match representative_file(&record.files) {
286+
Some((file_name, file_info)) if gated => {
287+
let mut matched = Vec::new();
288+
for copy in gate_copies {
289+
let status = verify_file_patch(copy, file_name, file_info).await.status;
290+
if variant_matches_installed(Some(&status)) {
291+
matched.push(copy);
292+
}
277293
}
294+
matched
278295
}
296+
_ => pkg_paths.iter().collect(),
297+
};
298+
if probe_copies.is_empty() {
299+
continue;
279300
}
280301
for (file_name, info) in &record.files {
281302
if info.before_hash.is_empty() || !missing.contains(&info.after_hash) {
282303
continue;
283304
}
284-
for pkg_path in pkg_paths {
305+
for pkg_path in &probe_copies {
285306
let verify = verify_file_patch(pkg_path, file_name, info).await;
286307
if verify.status == VerifyStatus::HashMismatch {
287308
needed.insert(info.after_hash.clone());
@@ -627,6 +648,25 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>
627648
}
628649
}
629650

651+
/// `paths` in order with every path that resolves to an already-listed
652+
/// directory dropped: two discovered site-packages paths can name ONE
653+
/// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it
654+
/// twice would report the second pass `already_patched`. A path that can't
655+
/// be canonicalized is kept as-is.
656+
async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec<PathBuf> {
657+
let mut seen: HashSet<PathBuf> = HashSet::new();
658+
let mut out = Vec::with_capacity(paths.len());
659+
for path in paths {
660+
let key = tokio::fs::canonicalize(path)
661+
.await
662+
.unwrap_or_else(|_| path.clone());
663+
if seen.insert(key) {
664+
out.push(path.clone());
665+
}
666+
}
667+
out
668+
}
669+
630670
/// The file whose verify status decides whether a release variant
631671
/// describes the installed distribution (fed to
632672
/// [`variant_matches_installed`]).
@@ -1881,22 +1921,31 @@ async fn apply_patches_inner(
18811921
continue;
18821922
}
18831923

1884-
// Patch EVERY coexisting gem store copy (the npm multi-copy
1885-
// precedent): leaving the other store pristine is a silent
1886-
// false "applied" for whichever bundler loads it, and the
1887-
// per-copy results below make the JSON summary count each
1888-
// patched copy — the signal a second copy exists. PyPI/Maven
1889-
// keep the one-representative contract: their crawlers resolve
1890-
// one install dir per version, and a second path can only
1891-
// alias the same logical install (re-patching it would produce
1892-
// the spurious `already_patched` double-patch the nuget
1893-
// first-wins restoration fixed).
1894-
let copy_paths: &[PathBuf] =
1895-
if matches!(Ecosystem::from_purl(purl), Some(Ecosystem::Gem)) {
1896-
pkg_paths.as_slice()
1897-
} else {
1898-
std::slice::from_ref(pkg_path)
1899-
};
1924+
// Patch EVERY coexisting gem store copy and every PyPI
1925+
// site-packages copy (the npm multi-copy precedent): leaving
1926+
// the other copy pristine is a silent false "applied" for
1927+
// whichever bundler / interpreter loads it, and the per-copy
1928+
// results below make the JSON summary count each patched copy
1929+
// — the signal a second copy exists. The Python crawler
1930+
// resolves one release in several candidate envs when it
1931+
// can't tell which one the project's tool runs (a Pipenv
1932+
// WORKON_HOME venv beside `./.venv`, #529) or which one
1933+
// `sys.path` shadows (the user site beside a system dir in
1934+
// global scope, #501), and rollback already restores every
1935+
// copy. A PyPI path that only ALIASES another (a symlinked
1936+
// site-packages) is collapsed by canonical path, so one
1937+
// install is never patched twice. Maven keeps the
1938+
// one-representative contract: its crawler resolves one
1939+
// install dir per version.
1940+
let pypi_copies: Vec<PathBuf>;
1941+
let copy_paths: &[PathBuf] = match Ecosystem::from_purl(purl) {
1942+
Some(Ecosystem::Gem) => pkg_paths.as_slice(),
1943+
Some(Ecosystem::Pypi) => {
1944+
pypi_copies = distinct_install_dirs(pkg_paths).await;
1945+
pypi_copies.as_slice()
1946+
}
1947+
_ => std::slice::from_ref(pkg_path),
1948+
};
19001949

19011950
// Copy CLASS decides FAILURE semantics (never write scope —
19021951
// patching a shared home's vulnerable copy is fine when it
@@ -2880,6 +2929,90 @@ mod tests {
28802929
);
28812930
}
28822931

2932+
/// Regression (#538 review): two PyPI copies of one release holding
2933+
/// DIFFERENT wheels. The apply loop gates each variant per copy, so the
2934+
/// variant installed only in the SECOND copy is attempted there; its
2935+
/// locally-modified non-representative file needs the full afterHash
2936+
/// blob. Gating against the first copy alone skipped that variant and
2937+
/// left the blob unfetched, so warn-and-apply failed on the second copy.
2938+
#[tokio::test]
2939+
async fn mismatch_blob_gaps_gates_pypi_variants_per_copy() {
2940+
use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes;
2941+
2942+
let dir = tempfile::tempdir().unwrap();
2943+
// Copy A: the wheel's distribution, pristine.
2944+
let copy_a = dir.path().join("a");
2945+
tokio::fs::create_dir_all(&copy_a).await.unwrap();
2946+
tokio::fs::write(copy_a.join("aaa.py"), b"wheel\n")
2947+
.await
2948+
.unwrap();
2949+
// Copy B: the sdist's distribution, with a locally modified
2950+
// non-representative file.
2951+
let copy_b = dir.path().join("b");
2952+
tokio::fs::create_dir_all(&copy_b).await.unwrap();
2953+
tokio::fs::write(copy_b.join("aaa.py"), b"sdist\n")
2954+
.await
2955+
.unwrap();
2956+
tokio::fs::write(copy_b.join("zzz.py"), b"locally modified\n")
2957+
.await
2958+
.unwrap();
2959+
let blobs = dir.path().join("blobs");
2960+
tokio::fs::create_dir_all(&blobs).await.unwrap();
2961+
2962+
let mut wheel_files = HashMap::new();
2963+
wheel_files.insert(
2964+
"aaa.py".to_string(),
2965+
PatchFileInfo {
2966+
before_hash: compute_git_sha256_from_bytes(b"wheel\n"),
2967+
after_hash: "1".repeat(64),
2968+
},
2969+
);
2970+
let mut manifest = manifest_with_record(
2971+
"pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl",
2972+
wheel_files,
2973+
);
2974+
let mut sdist_files = HashMap::new();
2975+
sdist_files.insert(
2976+
"aaa.py".to_string(),
2977+
PatchFileInfo {
2978+
before_hash: compute_git_sha256_from_bytes(b"sdist\n"),
2979+
after_hash: "2".repeat(64),
2980+
},
2981+
);
2982+
sdist_files.insert(
2983+
"zzz.py".to_string(),
2984+
PatchFileInfo {
2985+
before_hash: "3".repeat(64),
2986+
after_hash: "4".repeat(64),
2987+
},
2988+
);
2989+
manifest.patches.insert(
2990+
"pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz".to_string(),
2991+
PatchRecord {
2992+
uuid: "22222222-2222-4222-8222-222222222222".to_string(),
2993+
exported_at: "2024-01-01T00:00:00Z".to_string(),
2994+
files: sdist_files,
2995+
vulnerabilities: HashMap::new(),
2996+
description: "fixture".to_string(),
2997+
license: "MIT".to_string(),
2998+
tier: "free".to_string(),
2999+
},
3000+
);
3001+
let mut all_packages = HashMap::new();
3002+
all_packages.insert(
3003+
"pkg:pypi/foo@1.0.0".to_string(),
3004+
vec![copy_a.clone(), copy_b.clone()],
3005+
);
3006+
3007+
let needed =
3008+
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
3009+
assert_eq!(
3010+
needed,
3011+
HashSet::from(["4".repeat(64)]),
3012+
"the second copy's variant must queue its mismatched file's blob"
3013+
);
3014+
}
3015+
28833016
/// A variant with no content-modifying files (only new files) has
28843017
/// nothing to disqualify it: no representative, treated as a match —
28853018
/// the same no-files contract as core's `select_installed_variants`.

0 commit comments

Comments
 (0)