Skip to content

fix(libc): rename onto or from a Windows directory symbolic link like a link - #9661

Open
Gruzzberry wants to merge 3 commits into
boykopovar:mainfrom
Gruzzberry:fix/rename-onto-directory-link
Open

Gruzzberry wants to merge 3 commits into
boykopovar:mainfrom
Gruzzberry:fix/rename-onto-directory-link

Conversation

@Gruzzberry

@Gruzzberry Gruzzberry commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

On Windows, rename treated a symbolic link to a directory (or a junction) as the directory it names. FreeBSD 11.4's kern_renameat looks up the last component of both paths without FOLLOW, so a symbolic link is renamed or replaced as the link itself, whatever it points to. Measured on main d6eafa5 with a file, an empty directory and two links to a directory:

Call main (Windows) This PR / FreeBSD
rename(file, link to a directory) EISDIR 0, the link is replaced by the file
rename(link to a directory, existing file) ENOTDIR 0, the file is replaced by the link
rename(link to a directory, empty directory) 0, the directory is replaced EISDIR

The cause is RenamePath_nid_no_patch: under MinGW, std::filesystem::symlink_status reports a Windows directory symbolic link as a directory instead of a link. sourceIsDirectory and destinationIsDirectory now also require that the path is not a symbolic link or a junction, recognised by the reparse tag (IO_REPARSE_TAG_SYMLINK, IO_REPARSE_TAG_MOUNT_POINT). Other reparse points, such as cloud placeholder folders, are still directories. Everything after that decision is unchanged, including RenameNative_nid_no_patch, which already opens the source with FILE_FLAG_OPEN_REPARSE_POINT. Linux reports links as links and is unchanged.

Tested

Windows 11 (10.0.26200), WinLibs MinGW-w64 GCC 15.2.0 r7, Ninja, Release, -DBUILD_TESTING=ON, on main d6eafa5, Developer Mode on so links can be created.

  • guest_filesystem: new checks after the existing rename ones, with a directory, an empty directory, two directory links and two files: the three calls in the table give the FreeBSD results, the replaced file's contents and the linked directory's contents are checked, and the links are removed afterwards (with RemoveDirectoryW on Windows, since unlink of a directory link is fix(libkernel): remove a Windows directory junction without emptying its target #7530's area). Without link permission those checks are skipped with a message. On Windows the same three calls also run on two junctions made with mklink /J (absolute target), which needs no Developer Mode, so they run on every Windows machine (suggested by @JJenkx, who checked junctions locally first). On main the test fails at the first new check (rename(link to a directory, empty directory) returns 0), and with the symbolic-link cases skipped it fails at the first junction check; with this PR it passes, three runs out of three.
  • file_streams, guest_kernel_rename, guest_rename_dot, guest_kernel_sync, guest_kernel_sys, guest_kqueue, guest_path_case, guest_path_name_length, guest_posix_scalar_io (the other tests that call rename): pass.
  • Full suite on main d6eafa5 and on main with this PR merged together with feat(libkernel_sys): implement lchflags #9442, fix(libkernel): fail chflags through a dangling Windows symbolic link with ENOENT #9469, fix(libc): follow a Windows symbolic link in access #9474, fix(libkernel): follow a Windows symbolic link in chmod #9487 and fix(libkernel): fail open with O_CREAT and O_EXCL on a Windows symbolic link #9496, same machine: 1890 tests each, identical results (1869 passed, 18 skipped; on both, the 3 agc_driver_sample_order* tests fail and agc_driver_image_array_window_past_layer_limit is not built, as its 64 KiB alignas is rejected by this MinGW toolchain).
  • python tools/check_conventions.py --base upstream/main: passes.
  • Independent Windows check by @JJenkx on a second machine (Windows 11 Enterprise 10.0.22621): the same tests pass three times out of three and the new block fails on main; see the comment.
  • Not tested: Linux (no change there; the new checks create the links with std::filesystem::create_directory_symlink), a console, a title run.

Overlaps

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); no silent stubs added
  • No notes, investigation .md files or images added to the repository
  • New third-party code is a submodule built from source: none added
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

AI-assisted (Claude Code).

🤖 Generated with Claude Code

@JJenkx

JJenkx commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Independent Windows check of db944e4, on a second machine: Windows 11 Enterprise 10.0.22621, Ryzen 9 5950X, WinLibs GCC 15.2.0 r7 (UCRT), Ninja, Release, -DBUILD_TESTING=ON, Developer Mode on.

  • main d6eafa5 with only this PR's GuestFilesystem.cpp: guest_filesystem aborts in the new block, before any later check runs.
  • This PR: guest_filesystem, file_streams, guest_kernel_rename, guest_rename_dot, guest_kernel_sync, guest_kernel_sys, guest_kqueue, guest_path_case, guest_path_name_length and guest_posix_scalar_io pass in three runs out of three. The link cases ran each time; the "symbolic links unavailable" message never appeared.
  • Junctions, which the change covers through IO_REPARSE_TAG_MOUNT_POINT but the test doesn't create: I made a local, uncommitted copy of the new block that makes both links with mklink /J (absolute target) instead of CreateSymbolicLinkW and requires that both are created. It passes three times out of three with this PR and fails on main's Rename.cpp. So the three calls in the table give the same results for junctions as for directory symbolic links. If you want that covered, a junction variant of the block would be a small addition. Junctions need no Developer Mode, so it would also run on machines where the symbolic-link cases are skipped.

Not tested: Linux, a console, a title run.

@Gruzzberry

Copy link
Copy Markdown
Contributor Author

Thanks @JJenkx for the second-machine run and the junction check. Added in 2b62d4d: on Windows the same three calls now also run on two junctions made with mklink /J (absolute target), outside the symbolic-link branch, so they run without Developer Mode. With this PR guest_filesystem passes three runs out of three; with main's Rename.cpp and the symbolic-link cases skipped it fails at the first junction check. check_conventions.py passes, and the description now credits your run.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/libs/prx/libc/src/Rename.cpp
PR 9371 core/libs/tests/GuestFilesystem.cpp
PR 9473 core/libs/tests/GuestFilesystem.cpp
PR 9947 core/libs/tests/GuestFilesystem.cpp
PR 10319 core/libs/tests/GuestFilesystem.cpp
PR 10433 core/libs/tests/GuestFilesystem.cpp
PR 10620 core/libs/tests/GuestFilesystem.cpp

@fogydes

fogydes commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Reviewed; the semantic change itself is right for every reachable case: file → link-to-dir becomes a link-over-file rename instead of EISDIR, link-to-dir → plain file replaces the file, link-to-dir → empty dir keeps guest EISDIR (21) as the test asserts, dir → link-to-dir still answers ENOTDIR per POSIX/Linux, and dangling links stay non-dir via symlink_status. The native rename opens with FILE_FLAG_OPEN_REPARSE_POINT so it moves or replaces the link itself, other reparse tags (OneDrive placeholders etc.) stay untouched because only IO_REPARSE_TAG_SYMLINK/MOUNT_POINT match, and Linux is outside the #if fork. Test rows and cleanup look right.

One nit: in the success path, after unlink_nid_postfix(directoryLink) succeeds, the Windows cleanup still calls RemoveDirectoryW on directoryLink and movingLink, which are both gone or no longer directories at that point; the calls are silently ignored no-ops but could go.

@MarcZX10 MarcZX10 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Windows-only reparse-tag check is narrowly scoped to symbolic links and mount-point junctions, and leaves other reparse directories intact. The reported table and added file/directory-link cases cover the relevant replacement directions; the second-machine junction run adds useful coverage. Exact-head diff-check and conventions pass; hosted conventions/eligibility/labels/report plus relinker Linux/Windows/macOS and ASan jobs pass. Separately, the test cleanup nit noted in the thread remains: after the link/file replacement assertions, the later RemoveDirectoryW calls for movingLink and directoryLink target paths already moved or removed; they are harmless no-ops and can be dropped.

I’ve noticed that holding this PR because of #5239 isn’t necessary. It’s fine for PRs to keep coming in; they’ll be handled in due course. Please disregard my earlier request to hold or defer it.

@Gruzzberry
Gruzzberry marked this pull request as draft October 22, 2026 11:41
@Gruzzberry

Copy link
Copy Markdown
Contributor Author

Holding this as a draft, following the direction on #5239: the open queue is above 700 and no named title needs this change on current main. I will only mark it ready again with a current-main title ID, its Game bring-up discussion and the log line where it stops on this, or once the queue is below 700. Thanks for the reviews so far; the requested fixes are being made on the branch in the meantime.

@Gruzzberry
Gruzzberry force-pushed the fix/rename-onto-directory-link branch from 2b62d4d to 528fa6a Compare October 22, 2026 11:43
@Gruzzberry

Copy link
Copy Markdown
Contributor Author

Thanks @fogydes and @MarcZX10. The cleanup is fixed in 528fa6a: on success only the link now named rename-occupied.txt is removed, inside that branch, and the links and files a skipped run may leave are removed in the skipped branch, so no call targets a path that is already gone. Rebased onto main 8b3a6ba; guest_filesystem passes and check_conventions.py passes. The PR stays a draft under #5239 as noted above.

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The latest head fixes the stale-path cleanup nit: success and skipped-link paths now remove only entries that still exist. I fetched the current PR head 528fa6ab and independently reran git diff --check upstream/main...HEAD and python3 tools/check_conventions.py --base upstream/main --head HEAD; both pass. Hosted conventions, eligibility, labels/report, relinker Linux/macOS/Windows matrix, ASan, register-context and write-watch checks also pass on this head.

The PR's junction and Windows rename behavior remains supported by the author and independent Windows runs; the current hosted Windows product job is skipped, and I did not rerun that Windows-only test locally. I found no new code issue in the latest diff. The PR remains draft per your stated hold, so this is a review update rather than a readiness recommendation.

@github-actions github-actions Bot added the libc label Oct 22, 2026
@MarcZX10

MarcZX10 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

I’ve noticed that asking you to hold this PR was unnecessary. It’s fine to keep submitting PRs; maintainers will handle them in due course.

Gruzzberry and others added 3 commits October 23, 2026 14:40
… a link

FreeBSD 11.4's rename does not follow the last component of either path,
so a symbolic link is renamed or replaced as a link whatever it points
to. Under MinGW std::filesystem::symlink_status reports a Windows
directory symbolic link or junction as a directory, so rename refused a
file onto a link to a directory with EISDIR, refused a link to a
directory onto a file with ENOTDIR, and replaced an empty directory with
such a link instead of failing with EISDIR. RenamePath now treats a
symbolic link or junction, recognised by its reparse tag, as a
non-directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rename change recognises junctions by IO_REPARSE_TAG_MOUNT_POINT but
the test only made directory symbolic links. The same three calls now
also run on two junctions made with mklink /J, which needs no Developer
Mode, so the cases run where symbolic links cannot be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On success the moved link and the replaced link are already gone, so
only the link now named rename-occupied.txt is removed there; the links
and files a skipped run may leave behind are removed in that branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Gruzzberry

Copy link
Copy Markdown
Contributor Author

Rebased onto main 938812d and marked ready for review, as @MarcZX10 noted the #5239 hold does not need to apply here. On Windows (MinGW GCC 15.2.0): the focused tests pass, check_conventions.py passes, and the full suite (2029 tests) on main merged with #9442, #9469, #9474, #9487, #9496, #9661 and #9682 matches main itself, apart from parallel-run flakes that pass when run alone.

@oneandonlydean oneandonlydean self-assigned this Oct 25, 2026
@oneandonlydean
oneandonlydean dismissed MarcZX10’s stale review October 25, 2026 06:22

The cleanup nit is fixed (confirmed in the follow-up review) and remains fixed on the rebased head d1b9c7c, and the hold request was withdrawn.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants