Skip to content

Support AT_EMPTY_PATH in fchownat/fchmodat2#194

Merged
jserv merged 1 commit into
sysprog21:mainfrom
Max042004:fix-chown-overlay-partial
Jul 14, 2026
Merged

Support AT_EMPTY_PATH in fchownat/fchmodat2#194
jserv merged 1 commit into
sysprog21:mainfrom
Max042004:fix-chown-overlay-partial

Conversation

@Max042004

@Max042004 Max042004 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

fchownat() and fchmodat2() only accepted AT_SYMLINK_NOFOLLOW, so
*at(fd, "", ..., AT_EMPTY_PATH) returned EINVAL instead of operating on
fd itself. That is the only way to chown or chmod an O_PATH descriptor
(plain fchown()/fchmod() reject FD_PATH with EBADF, matching Linux), so
unlike the plain EBADF guard this flag combination needs real handling,
not just acceptance.

An empty path with AT_EMPTY_PATH now operates on dirfd directly, or the
current directory when dirfd is AT_FDCWD, mirroring the identical
AT_EMPTY_PATH branch already in fs-stat.c's stat_at_path.

Close #128


Summary by cubic

Adds full AT_EMPTY_PATH support to fchownat and fchmodat2 so an empty path targets the dirfd (or cwd for AT_FDCWD), enabling chown/chmod via O_PATH and matching Linux/stat_at_path. Also fixes missing FUSE/proc interception and a cwd race in fchownat.

  • Bug Fixes
    • fchownat: empty path + AT_EMPTY_PATH now chowns dirfd or "." (AT_FDCWD); returns ENOSYS for FUSE dirfds and EPERM for synthesized /proc; avoids a race by opening "." once for chown+stat; keep fchown(O_PATH) returning EBADF.
    • fchmodat2: empty path + AT_EMPTY_PATH now chmods dirfd or "." (AT_FDCWD); returns ENOSYS for FUSE dirfds and EPERM for synthesized /proc; keep fchmod(O_PATH) returning EBADF.
    • Tests: add test-fchownat-empty-path and test-fchmodat-empty-path (uses raw fchmodat2 syscall; skips if ENOSYS); wire into tests/test-matrix.sh and update baselines.

Written for commit 74513b9. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

@Max042004 Max042004 force-pushed the fix-chown-overlay-partial branch from fd10b66 to 63b7930 Compare July 11, 2026 16:41
@Max042004 Max042004 changed the title Support AT_EMPTY_PATH in fchownat Support AT_EMPTY_PATH in fchownat/fchmodat2 Jul 11, 2026

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

Append Close #128 at the end of git commit messages.

@Max042004 Max042004 force-pushed the fix-chown-overlay-partial branch 2 times, most recently from ca1fea1 to 153a72d Compare July 11, 2026 16:52
@jserv jserv requested a review from doanbaotrung July 11, 2026 16:54

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

Automated review of the AT_EMPTY_PATH handling. The common thread: the new empty-path branch returns before the normal path's reject_unsupported_fuse_path_op() / interception layer, so it treats some fd classes differently from every other *at op. errno preservation, translate_at_flags dropping AT_EMPTY_PATH, the O_PATH handling, sysroot parity, leaks, and the test-matrix count math all check out.

Comment thread src/syscall/fs.c
/* AT_EMPTY_PATH with an empty path chmods dirfd itself; see the identical
* branch in sys_fchownat above for the O_PATH/AT_FDCWD rationale.
*/
if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') {

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.

Empty-path branch bypasses FUSE interception (mirror at :2519 in fchownat). It returns before path_translate_at + reject_unsupported_fuse_path_op, so it diverges from the named path in two ways:

  • A dirfd inside a FUSE mount has host_fd == -1 (fuse.c:1893), so host_dirfd_ref_open returns EBADF here, whereas the named path returns ENOSYS.
  • If cwd is inside a FUSE mount, the AT_FDCWD sub-case calls host fchmodat(".", ...) directly, mutating the host backing path instead of returning ENOSYS.

Suggested fix: snapshot dirfd first and return -LINUX_ENOSYS for FD_FUSE_* to match reject_unsupported_fuse_path_op.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. dirfd is now snapshotted (fd_snapshot) before host_dirfd_ref_open: FD_FUSE_DEV/FD_FUSE_FILE/FD_FUSE_DIR return -ENOSYS, matching reject_unsupported_fuse_path_op's behavior for the named-path case. For the AT_FDCWD sub-case, fuse_resolve_at_path(LINUX_AT_FDCWD, ".", ...) now checks whether cwd is inside a FUSE mount before touching the host path (same helper dirfd_guest_base_path in path.c already uses for this). Applied identically to the mirrored fchownat branch.

Comment thread src/syscall/fs.c
}

host_fd_ref_t ref;
if (host_dirfd_ref_open(dirfd, &ref) < 0)

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.

Proc-intercept divergence from the mirrored stat_at_path (mirror at :2533 in fchownat). stat_at_path checks snap.type == FD_PATH && snap.proc_path[0] and routes to proc_intercept_stat; this branch skips that and fchmod/fchowns the backing host temp fd directly.

A guest that opens a synthesized /proc path O_PATH then calls fchownat(fd, "", ..., AT_EMPTY_PATH) silently mutates elfuse's internal temp file and reports success (Linux would return EPERM). chown_result's EPERM-to-fakeroot-success mapping hides it further.

Suggested fix: snapshot the fd before host_dirfd_ref_open; for FD_PATH with proc_path set, return EPERM instead of touching the backing file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. Same fd_snapshot up front: FD_PATH with proc_path set now returns -EPERM instead of touching the backing file, mirroring stat_at_path's check.

Verified the divergence and the fix with a standalone probe: pre-fix, fchmodat2(procfd_of_/proc/self/fd, "", 0700, AT_EMPTY_PATH) returned 0 and silently mutated elfuse's synthesized backing directory; post-fix it returns -EPERM.

I did try adding this as a permanent regression test, but pulled it back out — under the qemu lane (real Alpine kernel), root fchownat on a real procfs dentry via the same call actually succeeds (simple_setattr-style, no fs-specific restriction), so an EPERM assertion there would be asserting elfuse-only behavior against a real kernel and breaks qemu-aarch64 parity testing. Since this specific check protects an elfuse-internal invariant (the temp-file backing) rather than Linux-compatible behavior, I decided the code fix + the manual probe was better than a flaky/misleading cross-platform assertion. Happy to add it gated some other way if you'd prefer.

Comment thread src/syscall/fs.c Outdated
if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') {
int mac_flags = translate_at_flags(flags);
if (dirfd == LINUX_AT_FDCWD) {
int host_rc = fchownat(AT_FDCWD, ".", owner, group, mac_flags);

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.

TOCTOU on the AT_FDCWD overlay update. fchownat(AT_FDCWD, ".") here and the follow-up fstatat(AT_FDCWD, ".") at :2526 resolve "." twice. A concurrent chdir() in another guest thread between them records the requested uid/gid overlay against a different directory's dev/ino. The dirfd sub-case is immune because it fchown+fstat the same fd.

Suggested fix: open "." once and fchown(fd)+fstat(fd) on that single descriptor, like the dirfd branch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. The AT_FDCWD branch now opens "." once (open(".", O_RDONLY)) and does fchown(fd) + fstat(fd) on that single descriptor, same as the dirfd branch, instead of resolving "." twice via fchownat then fstatat.


static int fchmodat2(int dirfd, const char *path, mode_t mode, int flags)
{
return (int) syscall(SYS_fchmodat2, dirfd, path, (long) mode, (long) flags);

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.

No ENOSYS skip guard for fchmodat2. This issues raw fchmodat2(2) (SYS 452, Linux 6.6+) with no fallback. On a qemu fixture running a <6.6 kernel it fails hard rather than skipping. Worth confirming the fixture kernel is >= 6.6 (the count bump to 217 assumes it).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. Added an fchmodat2_supported() probe at the start of main() (via the same raw syscall, against the already-created fixture file with a no-op mode); if it returns ENOSYS the test now prints a skip message and returns 0 instead of running the rest and hard-failing.

For the record, the qemu lane's fixture is Alpine 3.21's linux-virt kernel, which is currently 6.12.x (>= 6.6, so fchmodat2 is present and the 217 count holds) — but it's fetched live and unpinned in-repo, so this guards against a future/older kernel there without needing to touch the count.

fchownat() and fchmodat2() only accepted AT_SYMLINK_NOFOLLOW, so
*at(fd, "", ..., AT_EMPTY_PATH) returned EINVAL instead of operating on
fd itself. That is the only way to chown or chmod an O_PATH descriptor
(plain fchown()/fchmod() reject FD_PATH with EBADF, matching Linux), so
unlike the plain EBADF guard this flag combination needs real handling,
not just acceptance.

An empty path with AT_EMPTY_PATH now operates on dirfd directly, or the
current directory when dirfd is AT_FDCWD, mirroring the identical
AT_EMPTY_PATH branch already in fs-stat.c's stat_at_path.

Neither AT_EMPTY_PATH branch went through path_translate_at, so both
missed the FUSE and /proc interception every other *at() path gets: a
FUSE-backed dirfd returned EBADF instead of ENOSYS, cwd inside a FUSE
mount was chmod/chowned on the host directly, and an O_PATH fd on a
synthesized /proc directory silently mutated elfuse's internal backing
file instead of failing. fchownat's AT_FDCWD case also resolved "."
twice (once to chown, once to stat), racing a concurrent chdir() on
another thread of the same guest. All four are fixed by checking the
dirfd's type up front and reusing a single open fd for the AT_FDCWD
chown+stat pair.

Close sysprog21#128
@Max042004 Max042004 force-pushed the fix-chown-overlay-partial branch from 153a72d to 74513b9 Compare July 14, 2026 11:07
@jserv jserv merged commit ea13cbb into sysprog21:main Jul 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AT_EMPTY_PATH with AT_FDCWD fails in sys_fchmodat and sys_fchownat

2 participants