Skip to content

fix(libkernel_sys): resolve linkat paths relative to a directory descriptor - #10434

Merged
oneandonlydean merged 2 commits into
boykopovar:mainfrom
Retr0G28:fix/linkat-directory-descriptor
Oct 25, 2026
Merged

oneandonlydean merged 2 commits into
boykopovar:mainfrom
Retr0G28:fix/linkat-directory-descriptor

Conversation

@Retr0G28

@Retr0G28 Retr0G28 commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What

linkat threw linkat: a relative path with a directory descriptor whenever fd1 or fd2 was not AT_FDCWD and its path was relative. FreeBSD 11's kern_linkat looks path1 up from fd1 and path2 from fd2, like every other *at call.

Each path is now resolved on its own, as renameat, readlinkat and unlinkat already do. AT_FDCWD or an absolute path goes through ResolvePathError_nid_no_patch. Any other relative path is joined to the descriptor's host directory from GetDirectoryDescriptorPath_nid_no_patch, which fails with EBADF for a descriptor that is not open and ENOTDIR for one that is not a directory, as namei does. A path of GuestPathMax or longer, or one with a component that is too long, fails with ENAMETOOLONG, as in renameat. The body of link after path resolution moves into LinkNative without changes, so link and linkat share the checks for EPERM on a directory, EEXIST, ENOENT, ENOTDIR and EXDEV.

kern_linkat finishes namei on path1, including the EPERM for a directory, before it looks at path2. linkat follows the same order. The source checks move out of LinkNative into LinkSourceError. linkat runs, in order: the EFAULT and ENOENT checks for path1, its resolution, and LinkSourceError. Only then does it check and resolve path2. So a missing source gives ENOENT and a directory source gives EPERM, even when fd2 is not open or path2 is null or empty. link resolves both paths and then calls LinkSourceError and LinkNative, so its order is unchanged.

An empty path now fails with ENOENT in linkat itself, as it did before through link. A symbolic link as the source without AT_SYMLINK_FOLLOW still throws, now also when it is named relative to a descriptor. The throw comes before the source checks, which would follow the link.

The debt entry takes the sentence readlinkat and unlinkat use for descriptor-relative paths, with the same limits: guest path aliases and case-insensitive name matching do not apply, and a .. component is not kept inside the guest root.

Test: the two ThrowsNaming checks in guest_kernel_sys become real links:

  • from a directory descriptor to an absolute path, then the other way round, each checked by hard_link_count;
  • EEXIST and ENOENT with both paths relative to the descriptor;
  • EBADF for an unopened fd1 or fd2, and ENOTDIR for a regular file's descriptor as fd1 or fd2;
  • the source is checked first: a missing source gives ENOENT with an unopened fd2 or a null path2, and a directory source gives EPERM with an unopened fd2 or an empty path2. A valid source with a null or empty path2 gives EFAULT or ENOENT;
  • after the failed calls, the target name does not exist.

Tested

Windows 11, WinLibs MinGW-w64 GCC 15.2.0 r7, CMake + Ninja, Release, -DBUILD_TESTING=ON, on main a23f8fc.

  • With main's Export.cpp, guest_kernel_sys fails (exit 0xc0000409: the call throws where the test expects a link). With this PR it passes.
  • With the first commit's Export.cpp (path2 resolved before the source check), the new ordering checks fail (exit 0xc0000409). With head 2d32e8b they pass.
  • guest_kernel_sys, guest_kernel_rename, guest_rename_dot and conventions_doc_links pass on 2d32e8b.
  • python tools/check_conventions.py --base origin/main reports no errors.
  • Linux: amazedturnip ran guest_kernel_sys on the first commit d44d568 (Ubuntu 24.04 on WSL2, GCC 13.3.0), and it passed. The ordering commit has not been run on Linux. No title or homebrew run.

Overlap: tools/pr_overlap.py finds no open PR that conflicts on the changed lines. Of the PRs it could not compare:

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); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@github-actions

github-actions Bot commented Oct 24, 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/libkernel_sys/Export.cpp
PR 7201 core/libs/prx/libkernel_sys/Export.cpp
PR 9019 core/libs/prx/libkernel_sys/Export.cpp
docs/dev/debt/libkernel_sys.md
PR 9097 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
PR 9161 core/libs/prx/libkernel_sys/Export.cpp
docs/dev/debt/libkernel_sys.md
PR 9297 docs/dev/debt/libkernel_sys.md
PR 9302 docs/dev/debt/libkernel_sys.md
PR 9423 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
PR 9442 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
PR 9573 docs/dev/debt/libkernel_sys.md
PR 9682 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
PR 9693 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
PR 9713 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
docs/dev/debt/libkernel_sys.md
PR 9826 core/libs/prx/libkernel_sys/Export.cpp
PR 10524 core/libs/prx/libkernel_sys/Export.cpp
docs/dev/debt/libkernel_sys.md
PR 10625 core/libs/prx/libkernel_sys/Export.cpp
core/libs/tests/GuestKernelSys.cpp
docs/dev/debt/libkernel_sys.md
PR 10657 docs/dev/debt/libkernel_sys.md

@amazedturnip

Copy link
Copy Markdown

Built and verified on Linux (Ubuntu 24.04 on WSL2, GCC 13.3.0, x86-64 AMD Ryzen 7 9800X3D) to cover the untested platform mentioned in the description:

  • Build: Clean compilation of \libkernel_sys.prx\ and \ ests/guest_kernel_sys_tests.
  • Test execution: \guest_kernel_sys_tests\ passes cleanly, specifically exercising the new \linkat\ relative descriptor paths (\OwnershipAndLinkTests), hardlink count verification (\hard_link_count == 3), error handling (\EEXIST, \ENOENT), and invalid descriptor paths (\EBADF, \ENOTDIR).
  • Conventions & Overlap: \python3 tools/check_conventions.py --base upstream/main\ passes with 0 errors, and \ ools/pr_overlap.py\ reports no merge conflicts with other open PRs.

Looks solid on Linux. LGTM!

@harisibrar1 harisibrar1 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.

Checked against main 11d6efa and FreeBSD 11.4 sys_link/sys_linkat/kern_linkat (vfs_syscalls.c) and namei (vfs_lookup.c):

  • The resolution matches main's other *at calls (mkdirat, unlinkat, renameat, readlinkat), and the EBADF/ENOTDIR for the descriptor line up with namei. The flag handling and the throw for a symlink source without AT_SYMLINK_FOLLOW match sys_linkat, and it still merges cleanly into main.
  • One ordering detail: kern_linkat runs namei on path1 completely, including the EPERM for a directory source, before it looks at path2. Here ResolveAtPath(fd2, …) (Export.cpp:491) runs before LinkNative checks the source. So linkat(dirfd, "missing.txt", 4321, "x", 0) returns EBADF where FreeBSD returns ENOENT, and a directory source with a bad fd2 gives EBADF instead of EPERM. Checking the source (existence and directory) before resolving path2 would fix it, and a test like linkat(directory, "missing.txt", 4321, "absent.txt", 0) == -1 && Error() == 2 would cover it.

Reviewed with AI assistance (Claude Code); the checks above were run locally on my machine.

@Retr0G28

Copy link
Copy Markdown
Contributor Author

@harisibrar1 Thanks, you're right. Fixed in 2d32e8b. The source checks move out of LinkNative into LinkSourceError, and linkat now finishes path1 before it touches path2. In order, it runs the EFAULT/ENOENT checks for path1, resolves path1 against fd1, throws for a symbolic link without AT_SYMLINK_FOLLOW, then checks for ENOENT/ENOTDIR/EPERM. Only then does it check and resolve path2.

New checks in guest_kernel_sys:

  • linkat(directory, "missing.txt", 4321, "absent.txt", 0) gives ENOENT, and so does linkat(directory, "missing.txt", AT_FDCWD, nullptr, 0).
  • A directory source with fd2 4321, or with an empty path2, gives EPERM.
  • A valid source with a null or empty path2 gives EFAULT or ENOENT.

These fail with the first commit's Export.cpp and pass on the head. guest_kernel_sys, guest_kernel_rename, guest_rename_dot and conventions pass on Windows. link keeps its order (both paths resolved, then the source checks).

@amazedturnip Thanks for the Linux run. I added it to the body for d44d568. The ordering commit is new, so it hasn't been run on Linux yet.

@harisibrar1 harisibrar1 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.

Re-checked head 2d32e8b against main 6cd36e6:

  • linkat now finishes path1 before it touches path2: resolution, the symlink throw, then LinkSourceError (Export.cpp:495-497), and only then the path2 EFAULT/ENOENT checks and ResolveAtPath(fd2, …) (Export.cpp:498-500), which matches the order in kern_linkat. linkat(directory, "missing.txt", 4321, "absent.txt", 0) now gives ENOENT, and a directory source with a bad fd2 gives EPERM.
  • Moving the checks into LinkSourceError keeps link's behaviour the same; LinkNative now only handles the destination and the native link.
  • The new cases in GuestKernelSys.cpp:174-179 cover each ordering case I raised.
  • It still merges cleanly into main, and main's linkat still throws for relative paths with a descriptor, so this is still needed. Thanks, looks good to me.

Reviewed with AI assistance (Claude Code); the checks above were run locally on my machine.

@oneandonlydean oneandonlydean self-assigned this Oct 25, 2026
@oneandonlydean
oneandonlydean merged commit 61fc572 into boykopovar:main Oct 25, 2026
12 checks passed
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