Skip to content

Fix Directory.Delete(path, recursive: true) failing on directories containing junctions#124830

Open
Copilot wants to merge 7 commits intomainfrom
copilot/fix-directory-delete-junctions
Open

Fix Directory.Delete(path, recursive: true) failing on directories containing junctions#124830
Copilot wants to merge 7 commits intomainfrom
copilot/fix-directory-delete-junctions

Conversation

Copy link
Contributor

Copilot AI commented Feb 24, 2026

Directory.Delete(path, recursive: true) on Windows throws and leaves the parent directory behind when the directory contains an NTFS junction. Symbolic links are unaffected.

Description

Root cause: IO_REPARSE_TAG_MOUNT_POINT is shared by both volume mount points and directory junctions. RemoveDirectoryRecursive called DeleteVolumeMountPoint for every entry with this tag, but that API only works for volume mount points — it always fails for junctions. The failure was stored directly in exception, which then suppressed the RemoveDirectory call's success and prevented the parent from being cleaned up.

Fix (FileSystem.Windows.cs): Stage the DeleteVolumeMountPoint error in a local mountPointException instead of the shared exception. Promote it to exception only if RemoveDirectory also fails (using the existing && exception == null pattern). For junctions, RemoveDirectory succeeds and the staged error is discarded.

// Before: DeleteVolumeMountPoint failure poisons `exception` even though
// RemoveDirectory succeeds immediately after for junctions.

// After:
Exception? mountPointException = null;
if (!Interop.Kernel32.DeleteVolumeMountPoint(mountPoint))
    mountPointException = ...; // staged locally

if (!Interop.Kernel32.RemoveDirectory(dirPath) && exception == null)
{
    // Volume mount point: surface the unmount error.
    // Junction: RemoveDirectory succeeds — never reaches here.
    exception = mountPointException ?? Win32Marshal.GetExceptionForWin32Error(errorCode, fileName);
}

Tests (Delete.Windows.cs):

  • RecursiveDelete_DirectoryContainingJunction — creates a parent containing a junction to a separate target (with a file inside), calls Directory.Delete(parent, true), and asserts the parent is removed while the junction target and its contents are untouched.
  • Delete_Junction — tests a junction specified directly as the root argument of Delete (non-recursive path), verifying the junction is removed without following it.
  • Delete_VolumeMountPoint — tests a volume mount point specified directly as the root argument of Delete, gated on elevated privileges and NTFS, marked [ActiveIssue] consistent with existing mount-volume tests.
Original prompt

This section details on the original issue you should resolve

<issue_title>Directory.Delete(path, recursive: true) fails on directories containing junctions</issue_title>
<issue_description>### Description

On Windows, when recursively deleting a directory containing a junction, System.IO.Directory.Delete(String, Boolean) fails.

Symbolic links work as expected.

Reproduction Steps

Since there's no API to create junctions, this is mostly easily reproduced in powershell:

New-Item -Type Directory 'parent'
New-Item -Type Directory 'target'
New-Item -Type Junction 'parent/link' -Target (Resolve-Path 'target')

try {
    [System.IO.Directory]::Delete((Resolve-Path "parent"), $true)
}
catch {
    $_.Exception.ToString() | Write-Host
}

Expected behavior

The parent directory and junction should be removed successfully.

Actual behavior

The junction is removed, but the parent directory is left behind. The exception message depends on whether the script is run as administrator or not:

Non-admin
System.Management.Automation.MethodInvocationException: Exception calling "Delete" with "2" argument(s): "Access to the path 'link' is denied."
 ---> System.UnauthorizedAccessException: Access to the path 'link' is denied.
   at System.IO.FileSystem.RemoveDirectoryRecursive(String fullPath, WIN32_FIND_DATA& findData, Boolean topLevel)
   at System.IO.FileSystem.RemoveDirectory(String fullPath, Boolean recursive)
   at CallSite.Target(Closure , CallSite , Type , Object , Boolean )
   --- End of inner exception stack trace ---
   at System.Management.Automation.ExceptionHandlingOps.CheckActionPreference(FunctionContext funcContext, Exception exception)     
   at System.Management.Automation.Interpreter.ActionCallInstruction`2.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
Admin
System.Management.Automation.MethodInvocationException: Exception calling "Delete" with "2" argument(s): "The parameter is incorrect. : 'link'"
 ---> System.IO.IOException: The parameter is incorrect. : 'link'
   at System.IO.FileSystem.RemoveDirectoryRecursive(String fullPath, WIN32_FIND_DATA& findData, Boolean topLevel)
   at System.IO.FileSystem.RemoveDirectory(String fullPath, Boolean recursive)
   at CallSite.Target(Closure , CallSite , Type , Object , Boolean )
   --- End of inner exception stack trace ---
   at System.Management.Automation.ExceptionHandlingOps.CheckActionPreference(FunctionContext funcContext, Exception exception)
   at System.Management.Automation.Interpreter.ActionCallInstruction`2.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)

Regression?

I'm able to reproduce this using the script in both powershell 5.1.19041.2673 (.NET Framework 4.8.4614.0), and powershell core 7.2.11 (.NET 6.0.16)

Known Workarounds

Since the junction is removed, the operation can simply be retried to workaround the issue.

Configuration

Reproduced on Windows 22H2 (build 19045.2846)

Other information

No response</issue_description>

<agent_instructions>Look at the existing discussion about it and the suggested/explored fix for this following similar dotnet/runtime existing pattern for the test coverage requested. At the end, give an md file detailing on the reasoning behind each decision taken and the technical explanation of the solution and the background (the request that satisfies and how it does it).</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@ Tagging subscribers to this area: @dotnet/area-system-io See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
Issue Details

Description

On Windows, when recursively deleting a directory containing a junction, System.IO.Directory.Delete(String, Boolean) fails.

Reproduction Steps

Since there's no API to create hardlinks (#69030), this is mostly easily reproduced in powershell:

New-Item -Type Directory 'parent'
New-Item -Type Directory 'target'
New-Item -Type Junction 'parent/link' -Target (Re...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes dotnet/runtime#86249

<!-- START COPILOT CODING AGENT TIPS -->
---

✨ Let Copilot coding agent [set things up for you](https://github.com/dotnet/runtime/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 2 commits February 24, 2026 22:33
…tions

- Use a local `mountPointException` variable in RemoveDirectoryRecursive when
  handling IO_REPARSE_TAG_MOUNT_POINT reparse points. Previously, if
  DeleteVolumeMountPoint() failed (as it always does for directory junctions,
  which share the same reparse tag as volume mount points), the error was stored
  in `exception`, causing the parent directory deletion to fail even though
  RemoveDirectory() would succeed.

- Now mountPointException is only promoted to exception if RemoveDirectory also
  fails. If RemoveDirectory succeeds (the normal case for junctions), the
  DeleteVolumeMountPoint error is discarded.

- Add test RecursiveDelete_DirectoryContainingJunction to verify the fix.

Co-authored-by: ViveliDuCh <[email protected]>
Copilot AI changed the title Fix Directory.Delete(path, recursive: true) failing on directories containing junctions Fix Directory.Delete(recursive: true) failing on directories containing junctions Feb 25, 2026
Copilot AI changed the title Fix Directory.Delete(recursive: true) failing on directories containing junctions Fix Directory.Delete(path, recursive: true) failing on directories containing junctions Feb 26, 2026
@ViveliDuCh ViveliDuCh marked this pull request as ready for review February 26, 2026 23:40
Copilot AI review requested due to automatic review settings February 26, 2026 23:40
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a bug where Directory.Delete(path, recursive: true) on Windows fails when the directory contains NTFS junctions. The root cause was that both junctions and volume mount points share the IO_REPARSE_TAG_MOUNT_POINT reparse tag, but the code incorrectly treated DeleteVolumeMountPoint failures for junctions as fatal errors, even though junctions can be successfully removed with RemoveDirectory.

Changes:

  • Modified error handling in RemoveDirectoryRecursive to stage DeleteVolumeMountPoint errors separately, only promoting them to fatal errors if RemoveDirectory also fails
  • Added comprehensive test coverage to verify junctions can be deleted recursively without following the junction target

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Windows.cs Refactored error handling to stage mount point deletion errors in a local variable, only promoting them to the shared exception if RemoveDirectory also fails; added clarifying comments about junction vs volume mount point behavior
src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/Delete.Windows.cs Added test that creates a parent directory containing a junction, verifies recursive deletion removes the parent and junction without following the junction target

Copy link
Member

@jozkee jozkee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix makes sense, I think there's a few tests to be added.

@copilot address this feedback.


// Note that RemoveDirectory on a symbolic link will remove the link itself.
if (!Interop.Kernel32.RemoveDirectory(Path.Combine(fullPath, fileName)) && exception == null)
if (!Interop.Kernel32.RemoveDirectory(Path.Combine(fullPath, fileName)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be reverted for simplification, and then you can remove if (exception == null).

Suggested change
if (!Interop.Kernel32.RemoveDirectory(Path.Combine(fullPath, fileName)))
if (!Interop.Kernel32.RemoveDirectory(Path.Combine(fullPath, fileName)) && exception == null)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 42c750e. Reverted to && exception == null and removed the inner if block.

string target = GetTestFilePath();
Directory.CreateDirectory(target);

string linkParent = GetTestFilePath();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise it makes you think this is also a link.

Suggested change
string linkParent = GetTestFilePath();
string parent = GetTestFilePath();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to parent in 42c750e.


Assert.False(Directory.Exists(linkParent), "parent should be deleted");
Assert.True(Directory.Exists(target), "target should still exist after deleting junction");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we include tests for the following:

  • A junction specified as the directory (as root) for Delete.
  • Assert that if the target contains elements, they are not removed (assuming that's the expected).
  • A volume mount point specified as the directory (as root) for Delete - not related to this PR but I see those needing 2 operations, DeleteVolumeMountPoint + RemoveDirectory when they are discovered as part of the enumeration, but when they are the root, we only call RemoveDirectory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 42c750e:

  • RecursiveDelete_DirectoryContainingJunction now creates a file inside the target and asserts it survives the junction deletion.
  • Delete_Junction: tests a junction specified as the root argument of Delete.
  • Delete_VolumeMountPoint: tests a volume mount point as the root argument of Delete, gated on IsPrivilegedAndNtfs and marked [ActiveIssue("https://github.com/dotnet/runtime/issues/14378")] consistent with the existing mount-volume tests.

…d junction tests

- Revert RemoveDirectory condition back to `&& exception == null` form per review suggestion
- Rename `linkParent` to `parent` in test
- Add assertion that target contents survive junction deletion
- Add Delete_Junction test (junction as root of Delete)
- Add Delete_VolumeMountPoint test (volume mount point as root)

Co-authored-by: jozkee <[email protected]>
Copilot AI requested a review from jozkee February 27, 2026 19:15
Copy link
Member

@jozkee jozkee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests using MountHelper.Mount have ActiveIssue #14378, if the test passes in CI, let's follow-up with cleaning them up, if it fails, let's investigate but that shouldn't block this PR.
Thanks, @ViveliDuCh.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants