馃寪 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

馃悰 fix(path): preserve link targets on delete errors - #3427

Merged
gaborbernat merged 7 commits into
pypa:mainfrom
gaborbernat:security/deletion-link-mode
Oct 17, 2026
Merged

gaborbernat merged 7 commits into
pypa:mainfrom
gaborbernat:security/deletion-link-mode

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

safe_delete removes an environment or a cache tree with shutil.rmtree and retried a failed unlink or rmdir after adding the owner write bit to the entry (old lines). That mode change went by pathname. When the entry was a symbolic link, the change landed on the file the link pointed at, and the mode it applied was the link's own. A directory the deleting user cannot write, holding a link to a file that user owns, therefore turned a virtualenv --clear or a cache refresh into a mode change on that file. 馃悰 After this PR a failed deletion on POSIX propagates its error without changing any mode, and on Windows the read-only attribute is cleared through a handle on the entry itself, which refuses reparse points. This addresses draft advisory ANT-2026-UK90QC27.

The retry changed the mode of the link's target

The handler #3222 made run let a FileNotFoundError pass, re-raised anything that was not an unlink or rmdir failure, and otherwise ran os.chmod(path, os.lstat(path).st_mode | S_IWUSR) and the deletion again (old lines). os.access and os.chmod follow symbolic links by default, as chmod(2) does for a pathname, and os.supports_follow_symlinks does not list os.chmod on Linux, so there was no portable way to change the link itself. os.lstat read the link's own mode, which symlink(7) fixes at 0777 on Linux. The target therefore received 0777: readable, writable and executable by everyone. The retried deletion then failed again, since unlink(2) and rmdir need write permission on the parent directory and never consult the entry's mode. The caller got the same error as before, and the target kept its new mode.

The handler now re-raises every error on POSIX other than a missing entry. The retry remains for Windows, where a read-only attribute does block deletion, and skips symbolic links there as well.

sequenceDiagram
    participant H as handler
    participant L as link in the tree
    participant T as link target (user-owned file)
    H->>L: unlink fails, parent directory not writable
    rect rgb(255, 214, 214)
    H->>L: lstat: mode 0777, the link's own
    H->>T: chmod(path) follows the link: target set to 0777
    H->>L: unlink again, fails for the same reason
    end
    rect rgb(216, 243, 220)
    H-->>H: now: raise the original error, no chmod
    end
Loading

Windows recovery binds the attribute change to the opened entry

On Windows the retry goes through clear_readonly. It opens the path with CreateFileW for FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES, with FILE_SHARE_READ | FILE_SHARE_WRITE but not FILE_SHARE_DELETE, as OPEN_EXISTING, and with FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT. Backup semantics lets the call open a directory. Opening the reparse point gives a handle to a link or junction itself rather than to its target. Withholding delete sharing means nobody can rename or remove the entry while the handle is open, so the change below applies to the entry that was opened. GetFileInformationByHandleEx reads the attributes into a FILE_BASIC_INFO (the struct). An entry with FILE_ATTRIBUTE_REPARSE_POINT raises OSError. Otherwise only FILE_ATTRIBUTE_READONLY is cleared, with FILE_ATTRIBUTE_NORMAL substituted when no attribute would remain, and SetFileInformationByHandle writes the structure back with the timestamps as read. A failed call raises the Windows error. ctypes is imported only on Windows.

flowchart LR
    E[rmtree reports a failure] --> N{FileNotFoundError?}
    N -- yes --> I[ignore]
    N -- no --> P{platform}
    P -- before, any --> B[lstat the link, chmod by path: target gets 0777, retry fails]
    P -- POSIX --> R[raise the original error]
    P -- Windows --> F{unlink or rmdir, not a symlink, not writable?}
    F -- no --> R
    F -- yes --> O[CreateFileW on the entry itself, no FILE_SHARE_DELETE]
    O --> T{FILE_ATTRIBUTE_REPARSE_POINT?}
    T -- yes --> X[OSError, nothing changed]
    T -- no --> C[clear READONLY by handle, retry]
    classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;
    classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;
    class B bad;
    class R,X,C ok;
Loading

Behavior changes

A POSIX deletion that fails for any reason other than a missing entry raises that error at once, and every mode in the tree stays as it was. On Windows, read-only files and directories are still removed. A symbolic link or junction that Windows refuses to delete now raises instead of being touched, as does any entry whose attributes cannot be read or written; the error is a PermissionError carrying the Windows error code. A parent directory swapped between rmtree's failure and the handle open is outside this protection: the handle guards the entry it opened, not the path that led to it.

Reported by Anthropic's OSS Scanner: ANT-2026-UK90QC27.

@gaborbernat gaborbernat added bug security Fixes a vulnerability or hardens the supply chain, CI or release labels Oct 16, 2026
Reported by Anthropic's OSS Scanner: ANT-2026-UK90QC27.
POSIX deletion failures do not justify changing the entry mode. Windows
still needs to clear read-only attributes, using a handle that refuses
reparse points and prevents replacement during the update.
Importing virtualenv without ctypes already works on main: _win.py
imported ctypes inside get_short_path_name, which only Windows calls.
The fragment described a non-change.
@gaborbernat
gaborbernat force-pushed the security/deletion-link-mode branch from 46804c1 to 9e2bf1c Compare October 17, 2026 10:01
@gaborbernat
gaborbernat marked this pull request as ready for review October 17, 2026 10:16
@gaborbernat
gaborbernat merged commit 2bfdc02 into pypa:main Oct 17, 2026
74 checks passed
@gaborbernat
gaborbernat deleted the security/deletion-link-mode branch October 17, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug security Fixes a vulnerability or hardens the supply chain, CI or release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant