From 077f96868dd065bc5a489e1e9d41318327dc9daf Mon Sep 17 00:00:00 2001 From: Oshadha Gunawardena Date: Wed, 22 Apr 2026 16:40:13 +0530 Subject: [PATCH 1/3] Avoid removing site-packages for empty egg-info top_level.txt --- crates/uv-install-wheel/src/uninstall.rs | 68 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/uv-install-wheel/src/uninstall.rs b/crates/uv-install-wheel/src/uninstall.rs index d3e5aa90c8d04..de4b5dded0d06 100644 --- a/crates/uv-install-wheel/src/uninstall.rs +++ b/crates/uv-install-wheel/src/uninstall.rs @@ -225,12 +225,14 @@ pub fn uninstall_egg( .parent() .expect("egg-info directory is not in a site-packages directory"); - // Read the `namespace_packages.txt` file. + // Read the `namespace_packages.txt` file, skipping empty or whitespace-only entries. let namespace_packages = { let namespace_packages_path = egg_info.join("namespace_packages.txt"); match fs_err::read_to_string(namespace_packages_path) { Ok(namespace_packages) => namespace_packages .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect::>(), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -241,13 +243,21 @@ pub fn uninstall_egg( }; // Read the `top_level.txt` file, ignoring anything in `namespace_packages.txt`. + // + // Empty or whitespace-only entries are skipped: legacy setuptools writes `top_level.txt` + // with a trailing newline even when the package has no top-level modules, which + // `str::lines` yields as an empty string. Joining that onto `dist_location` would + // resolve back to `dist_location` itself (site-packages), and a subsequent + // `remove_dir_all` would wipe out every installed package. See #19113. let top_level = { let top_level_path = egg_info.join("top_level.txt"); match fs_err::read_to_string(&top_level_path) { Ok(top_level) => top_level .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .filter(|line| !namespace_packages.iter().any(|ns| ns.as_str() == *line)) .map(ToString::to_string) - .filter(|line| !namespace_packages.contains(line)) .collect::>(), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Err(Error::MissingTopLevel(top_level_path)); @@ -535,4 +545,58 @@ mod tests { assert!(!init_py.exists()); assert!(!egg_info.exists()); } + + /// Regression test for . + /// + /// Legacy setuptools writes a `top_level.txt` that contains just a newline when the + /// distribution has no top-level modules. Previously, [`uninstall_egg`] parsed that as a + /// single empty entry, joined it onto `site-packages`, and called `remove_dir_all` on the + /// result, wiping out every other package in the environment. Uninstalling such a package + /// must leave its siblings untouched. + #[test] + fn test_uninstall_egg_info_empty_top_level() { + let venv = assert_fs::TempDir::new().unwrap(); + let site_packages = venv.child("lib/python3.12/site-packages"); + site_packages.create_dir_all().unwrap(); + + // A sibling package that must survive the uninstall. + let sibling_init = site_packages.child("sibling").child("__init__.py"); + sibling_init.touch().unwrap(); + let sibling_dist_info = site_packages.child("sibling-1.0.0.dist-info"); + sibling_dist_info.create_dir_all().unwrap(); + + // The egg-info for the package we're uninstalling, with a `top_level.txt` that + // contains only a newline (as legacy setuptools writes for an empty package). + let egg_info = site_packages.child("emptypkg-0.1.0.egg-info"); + egg_info.create_dir_all().unwrap(); + egg_info.child("top_level.txt").write_str("\n").unwrap(); + + let layout = Layout { + sys_executable: venv.path().join("bin/python"), + python_version: (3, 13), + os_name: "posix".to_string(), + scheme: Scheme { + purelib: site_packages.to_path_buf(), + platlib: site_packages.to_path_buf(), + scripts: venv.path().join("bin"), + data: venv.path().to_path_buf(), + include: venv.path().join("include/python3.12"), + }, + }; + + uninstall_egg(egg_info.path(), "emptypkg 0.1.0", &layout).unwrap(); + + // The egg-info is gone, but the rest of site-packages (including the sibling + // package) survives. + assert!(!egg_info.exists()); + assert!( + site_packages.exists(), + "uninstall must not remove site-packages itself" + ); + assert!(sibling_init.exists(), "sibling package must not be removed"); + assert!( + sibling_dist_info.exists(), + "sibling dist-info must not be removed" + ); + } } From 5a6c45600e32386e337de1a8581ad13560f25ae9 Mon Sep 17 00:00:00 2001 From: Oshadha Gunawardena Date: Wed, 22 Apr 2026 16:57:38 +0530 Subject: [PATCH 2/3] Add test for blank lines in top_level.txt Checks that uninstall ignores blank or whitespace-only lines in top_level.txt, still removes valid entries, and does not touch site-packages. --- crates/uv-install-wheel/src/uninstall.rs | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/uv-install-wheel/src/uninstall.rs b/crates/uv-install-wheel/src/uninstall.rs index de4b5dded0d06..0d0cb78a813da 100644 --- a/crates/uv-install-wheel/src/uninstall.rs +++ b/crates/uv-install-wheel/src/uninstall.rs @@ -599,4 +599,59 @@ mod tests { "sibling dist-info must not be removed" ); } + + /// Same bug shape as #19113, but triggered by a blank or whitespace-only line embedded + /// between valid entries in `top_level.txt`. Exercises the filter in combination with + /// real entries to make sure they're still honored after skipping empties. + #[test] + fn test_uninstall_egg_info_blank_lines_in_top_level() { + let venv = assert_fs::TempDir::new().unwrap(); + let site_packages = venv.child("lib/python3.12/site-packages"); + site_packages.create_dir_all().unwrap(); + + // A sibling package that must survive. + let sibling_init = site_packages.child("sibling").child("__init__.py"); + sibling_init.touch().unwrap(); + + // Two real top-level modules that should be removed. + let pkg_a_init = site_packages.child("pkg_a").child("__init__.py"); + pkg_a_init.touch().unwrap(); + let pkg_b_init = site_packages.child("pkg_b").child("__init__.py"); + pkg_b_init.touch().unwrap(); + + // `top_level.txt` with a leading blank line, a whitespace-only line between the two + // valid entries, a trailing blank line, and `\r\n` line endings mixed in. + let egg_info = site_packages.child("mixedpkg-0.1.0.egg-info"); + egg_info.create_dir_all().unwrap(); + egg_info + .child("top_level.txt") + .write_str("\npkg_a\n \r\npkg_b\n\n") + .unwrap(); + + let layout = Layout { + sys_executable: venv.path().join("bin/python"), + python_version: (3, 13), + os_name: "posix".to_string(), + scheme: Scheme { + purelib: site_packages.to_path_buf(), + platlib: site_packages.to_path_buf(), + scripts: venv.path().join("bin"), + data: venv.path().to_path_buf(), + include: venv.path().join("include/python3.12"), + }, + }; + + uninstall_egg(egg_info.path(), "mixedpkg 0.1.0", &layout).unwrap(); + + // The two named packages are gone, the egg-info is gone, and site-packages plus + // the sibling survive. + assert!(!egg_info.exists()); + assert!(!pkg_a_init.exists(), "pkg_a must be removed"); + assert!(!pkg_b_init.exists(), "pkg_b must be removed"); + assert!( + site_packages.exists(), + "uninstall must not remove site-packages itself" + ); + assert!(sibling_init.exists(), "sibling package must not be removed"); + } } From a4de4c5a31842bfc68cda2cfea34d09f5d534c9d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 4 May 2026 14:49:55 -0700 Subject: [PATCH 3/3] Tweak comment --- crates/uv-install-wheel/src/uninstall.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-install-wheel/src/uninstall.rs b/crates/uv-install-wheel/src/uninstall.rs index 0d0cb78a813da..5e1a2a200b8a8 100644 --- a/crates/uv-install-wheel/src/uninstall.rs +++ b/crates/uv-install-wheel/src/uninstall.rs @@ -248,7 +248,7 @@ pub fn uninstall_egg( // with a trailing newline even when the package has no top-level modules, which // `str::lines` yields as an empty string. Joining that onto `dist_location` would // resolve back to `dist_location` itself (site-packages), and a subsequent - // `remove_dir_all` would wipe out every installed package. See #19113. + // `remove_dir_all` would wipe out every installed package. let top_level = { let top_level_path = egg_info.join("top_level.txt"); match fs_err::read_to_string(&top_level_path) {