Skip to content

🐛 fix(util): replace a stale symlink instead of writing through it - #3229

Open
darrenhuai wants to merge 2 commits into
pypa:mainfrom
darrenhuai:fix/dangling-symlink-dest
Open

🐛 fix(util): replace a stale symlink instead of writing through it#3229
darrenhuai wants to merge 2 commits into
pypa:mainfrom
darrenhuai:fix/dangling-symlink-dest

Conversation

@darrenhuai

@darrenhuai darrenhuai commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for contributing, make sure you address all the checklists (for details on how see development documentation)

  • ran the linter to address style issues (tox -e fix)
  • wrote descriptive pull request text
  • ensured there are test(s) validating the fix
  • added news fragment in docs/changelog folder
  • updated/extended the documentation

ensure_safe_to_do clears whatever sits at the destination before a ref is copied or symlinked into an environment, but it decides there is nothing to clear with dest.exists():

if not dest.exists():
    return

That call follows the link, so a symlink whose target is gone reads as an empty slot and the destination is left in place.

An environment hits this as soon as its interpreter moves. Create one with --symlinks, let pyenv or brew or a distro upgrade remove the interpreter that bin/python points at, then run virtualenv over the same directory again to refresh it.

In symlink mode the recreate dies:

FileExistsError: [Errno 17] File exists: '/usr/bin/python3.12' -> '/tmp/env/bin/python'

In copy mode it is quieter and worse. shutil.copy opens the destination for writing, follows the stale link, and drops the interpreter at whatever the link used to point to — outside the environment:

=== re-run with --copies over the same dir ===
created virtual environment CPython3.12.3.final.0-64-x86_64 in 1670ms
--- exit code: 0 ---
inside venv (env/bin/python):
lrwxrwxrwx 1 root root      33 env/bin/python -> /tmp/pythons/python3.12
OUTSIDE the venv (pythons/):
-rwxr-xr-x 1 root root 8020928 python3.12

virtualenv reports success, the environment keeps a symlink pointing out of itself instead of the copy --copies asked for, and an 8 MB binary is left behind somewhere else on disk.

Checking is_symlink() as well treats a dangling link as something in the way, so it is unlinked and replaced like any other occupied destination. With the fix the same run puts a regular file at env/bin/python and leaves nothing outside.

Two tests, one per mode: symlink replaces a dangling destination rather than raising, and copy writes a real file at the destination rather than through the stale link. Both fail on main. They need a filesystem that can make symlinks, so they use the same pytest.skip guard already used elsewhere in the file.

Verified on Linux (3.12, where the symlink tests actually run) and on Windows (3.14, tests/unit is 311 passed / 35 skipped, the two new ones among the skips since this machine cannot create symlinks). ruff and ty check src/virtualenv pass against both the 3.9 and 3.14 targets.

I also tried the same guard on the alias cleanup in ExePathRefToDest.run, which has the same shape. Reverting it changed no behaviour I could produce — the base link is rewritten before the aliases resolve through it — so I left that line alone rather than add an untested change.

`ensure_safe_to_do` clears whatever sits at the destination before a ref is
copied or symlinked into an environment, but it decides there is nothing to
clear with `dest.exists()`. That call follows the link, so a symlink whose
target is gone reads as an empty slot and the destination is left in place.

An environment hits this as soon as its interpreter moves: create it with
`--symlinks`, let pyenv or brew or a distro upgrade remove the interpreter
that `bin/python` points at, then run virtualenv over the same directory
again, which is the supported way to refresh one.

In symlink mode the recreate dies with an uncaught `FileExistsError`, because
`symlink_to` gets a path that is still occupied. In copy mode it is quieter
and worse: `shutil.copy` opens the destination for writing, follows the stale
link, and drops the interpreter at whatever the link used to point to -
outside the environment. virtualenv reports success, the environment keeps a
symlink pointing out of itself instead of the copy `--copies` asked for, and
an 8 MB binary is left behind somewhere else on disk.

Checking `is_symlink()` as well treats a dangling link as something in the
way, so it is unlinked and replaced like any other occupied destination.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant