@@ -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