Repository navigation
Delete a Physical folder through handles, so no link swapped in can steer it outside the root - #76
Merged
Merged
Conversation
…teer it outside the root Refs #70
…cancelled Some file systems (FUSE, some NFS servers) list '.' and '..' as files, which .NET then reports: opened in the folder's handle, '..' would have been its parent and '.' the folder again. The walk now skips them and refuses a name holding a separator, and lists through the system seam so a test can hand it such names. A subfolder gone between its open and its listing is skipped, and Windows' refusal to remove a folder already deleted counts as gone. The walk checks the cancellation token between entries. A test holds folders on the way open without delete sharing, which fails if the walk asks for delete access there. The README says where the walk runs and that elsewhere a delete by path can still be steered; unused classifications go. Refs #70
An empty name, opened relative to a folder's handle on Windows, is the folder itself: refused as a name with a separator is. A removal refused for a name another process deleted counts as gone, and that check now looks the name up in the extended form on Windows, which keeps a trailing dot or space, so 'x.' is never taken for 'x'. Also: the changelog's cancellation scoped to where the walk runs, wraps, a dead test override, and the plan's table noting ENOTEMPTY is no longer read. Refs #70
serionist
marked this pull request as ready for review
October 13, 2026 10:47
serionist
added a commit
that referenced
this pull request
Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Step 3 of the 2027.1.1 plan. Physical deleted a folder with .NET's
Directory.Delete(path, recursive: true), which goes into each subfolder by its path: a process that can create links inside the root could swap a subfolder, or a folder above it, for a link while the delete ran and steer it into a folder elsewhere, outside the root, withRefuseLinkson or off (#70). Now a folder is deleted through handles.FolderDelete, shared): each entry is opened relative to its folder's handle without following a link and removed relative to that handle, or through its own on Windows. A link inside the folder, even one swapped in while the delete runs, is removed as itself and never gone into. WithoutRefuseLinks, the folder that holds the target is opened by its path (links followed, as every member does); with it, the walk starts at the root and opens every folder on the way without following one, so a link swapped in there after the check is refused with the sameArgumentException. Names come from listing each folder by its path; every act goes through a handle, so a listing that a rename or a swapped link made read elsewhere can only make the delete miss names and fail. Errors as .NET's recursive delete gives them: it goes on past what it can't delete, skips what's gone, and throws the first failure.UnixFolders):openatwithO_DIRECTORY | O_NOFOLLOW(andO_PATHon Linux, which needs no read permission),readlinkatto tell a link from a file,unlinkat. The flags and error numbers come from a table picked by system andProcessArchitecture: Linux x64, Arm and Arm64, macOS.WindowsFolders):NtCreateFilerelative to the folder's handle withFILE_OPEN_REPARSE_POINT; a name-surrogate reparse point (a symbolic link, a junction, a mount point) is a link; each entry is deleted through its handle with POSIX semantics, falling back to the classic delete where the file system refuses them.Directory.Delete, as before, and the docs say so.UnauthorizedAccessException); a folder that still has entries after the walk throwsIOExceptionwhere it returned true; on macOS the folder above the deleted one (and withRefuseLinksevery folder on the way) must be readable. A file is still deleted by its path.WindowsFolders, which holds only the Windows calls and their argument marshalling, is[ExcludeFromCodeCoverage]with the reason. Everything that decides is shared code measured on Linux: what an entry is (WindowsRules,UnixTable), what each error means, when to fall back, the\\?\form. The Windows job runs every line ofWindowsFolders, the classic delete through a test switch.Docs: Physical's class remarks (a new paragraph on deleting a folder),
RefuseLinks' remarks (the recursive delete's caveat goes), the Physical README,AGENTS.md's Physical tests line, the changelog, the plan's progress log.Plan
docs/plans/2026-10-13-2027-1-1.md, step 3, approved by @serionist. The progress log records where the code departs from the plan's names (FoldersforDeletesByHandle,ClassicDeletes).Tested by
PhysicalFolderDeleteTests(new), on Linux, Windows, macOS and Linux Arm64:Openinghook, a subfolder swapped for a link to a folder outside the root while its parent is deleted: the link goes, the outside folder keeps everything (withRefuseLinkson and off); withRefuseLinks, a folder on the way swapped for a link after the check is refused, and so is the target swapped for one; a folder on the way swapped for a file deletes nothing; a target folder replaced by a file is deleted; an entry turned from a folder into a file, or gone between the listing and its open; a target gone before it is opened;RefuseLinksit is refused; a folder holding a link to a folder (a junction on Windows), a link to a file and a link to nothing is deleted, the targets kept;UnauthorizedAccessException, the rest deleted;IOException;\\?\form of a drive path, a drive's root and a share.PhysicalLinkAndSweepTestsnow runs through the walk.Livewith Docker required (Azurite, S3Mock, MinIO, fake-gcs-server), on Windows: 2,833 tests, 0 failed. Physical on Linux x64 in a container, as a user who isn't root: 334 tests, 0 failed (the rest inconclusive for Windows-only cases).openaton x64, Arm64 and macOS and throughNtCreateFileon Windows.Review
review-code, deep tier (Opus, on the maintainer's choice: 23 agents, every lens, solo refuters and three voters), then two standard rounds on the fixes: clean on 8ce8a2d, plus one test nit applied in 372a287.
.and..as files, which .NET reports; opened relative to the folder's handle,..would have emptied the parent, possibly outside the root, and.recursed until the stack overflowed. A listed name is now only ever an entry of its folder:.and..skipped, a separator refused; listing went behind the seam so a test hands the walk those names. F2: the plan's R3 test (folders on the way held open without delete sharing, as a shell holds its current folder), shown to fail when the walk asks for delete access there. F3: the README said less than the remarks about where the walk runs and that a delete by path can still be steered. Minors: a subfolder gone between its open and its listing is skipped (F4, reproduced by a refuter); the walk is cancellable between entries (F5); two simplifications (F6, F7). F8 (dropClassicDeletes) declined: the plan names the switch.x.read asx); it uses the extended form now, with a test that fails without it. Nits.GetFullPaththrows before the parent is touched);...,..and stream names on Windows (relative NT opens take them literally, inside the folder); skipping./..silently rather than recording them;IsGonehiding a real refusal;AllowUnsafeBlocksin the test project.One-way doors
None. No public or protected member is added or changed; the configuration shape is untouched.
Closes #70