@@ -627,6 +627,25 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>
627627 }
628628}
629629
630+ /// `paths` in order with every path that resolves to an already-listed
631+ /// directory dropped: two discovered site-packages paths can name ONE
632+ /// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it
633+ /// twice would report the second pass `already_patched`. A path that can't
634+ /// be canonicalized is kept as-is.
635+ async fn distinct_install_dirs ( paths : & [ PathBuf ] ) -> Vec < PathBuf > {
636+ let mut seen: HashSet < PathBuf > = HashSet :: new ( ) ;
637+ let mut out = Vec :: with_capacity ( paths. len ( ) ) ;
638+ for path in paths {
639+ let key = tokio:: fs:: canonicalize ( path)
640+ . await
641+ . unwrap_or_else ( |_| path. clone ( ) ) ;
642+ if seen. insert ( key) {
643+ out. push ( path. clone ( ) ) ;
644+ }
645+ }
646+ out
647+ }
648+
630649/// The file whose verify status decides whether a release variant
631650/// describes the installed distribution (fed to
632651/// [`variant_matches_installed`]).
@@ -1881,22 +1900,31 @@ async fn apply_patches_inner(
18811900 continue ;
18821901 }
18831902
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- } ;
1903+ // Patch EVERY coexisting gem store copy and every PyPI
1904+ // site-packages copy (the npm multi-copy precedent): leaving
1905+ // the other copy pristine is a silent false "applied" for
1906+ // whichever bundler / interpreter loads it, and the per-copy
1907+ // results below make the JSON summary count each patched copy
1908+ // — the signal a second copy exists. The Python crawler
1909+ // resolves one release in several candidate envs when it
1910+ // can't tell which one the project's tool runs (a Pipenv
1911+ // WORKON_HOME venv beside `./.venv`, #529) or which one
1912+ // `sys.path` shadows (the user site beside a system dir in
1913+ // global scope, #501), and rollback already restores every
1914+ // copy. A PyPI path that only ALIASES another (a symlinked
1915+ // site-packages) is collapsed by canonical path, so one
1916+ // install is never patched twice. Maven keeps the
1917+ // one-representative contract: its crawler resolves one
1918+ // install dir per version.
1919+ let pypi_copies: Vec < PathBuf > ;
1920+ let copy_paths: & [ PathBuf ] = match Ecosystem :: from_purl ( purl) {
1921+ Some ( Ecosystem :: Gem ) => pkg_paths. as_slice ( ) ,
1922+ Some ( Ecosystem :: Pypi ) => {
1923+ pypi_copies = distinct_install_dirs ( pkg_paths) . await ;
1924+ pypi_copies. as_slice ( )
1925+ }
1926+ _ => std:: slice:: from_ref ( pkg_path) ,
1927+ } ;
19001928
19011929 // Copy CLASS decides FAILURE semantics (never write scope —
19021930 // patching a shared home's vulnerable copy is fine when it
0 commit comments