-
Notifications
You must be signed in to change notification settings - Fork 15
Support AT_EMPTY_PATH in fchownat/fchmodat2 #194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2410,16 +2410,40 @@ int64_t sys_fchmodat(guest_t *g, | |
| int flags) | ||
| { | ||
| char path[LINUX_PATH_MAX]; | ||
| if (!validate_at_flags(flags, LINUX_AT_SYMLINK_NOFOLLOW)) | ||
| if (!validate_at_flags(flags, | ||
| LINUX_AT_SYMLINK_NOFOLLOW | LINUX_AT_EMPTY_PATH)) | ||
| return -LINUX_EINVAL; | ||
| if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) | ||
| return -LINUX_EFAULT; | ||
|
|
||
| /* 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') { | ||
| if (dirfd == LINUX_AT_FDCWD) { | ||
| if (fchmodat(AT_FDCWD, ".", mode, translate_at_flags(flags)) < 0) | ||
| return linux_errno(); | ||
| return 0; | ||
| } | ||
|
|
||
| host_fd_ref_t ref; | ||
| if (host_dirfd_ref_open(dirfd, &ref) < 0) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 A guest that opens a synthesized /proc path O_PATH then calls 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. |
||
| return -LINUX_EBADF; | ||
| if (fchmod(ref.fd, mode) < 0) { | ||
| host_fd_ref_close(&ref); | ||
| return linux_errno(); | ||
| } | ||
| host_fd_ref_close(&ref); | ||
| return 0; | ||
| } | ||
|
|
||
| path_translation_t tx; | ||
| int64_t rc = read_translated_path( | ||
| g, dirfd, path_gva, | ||
| (flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE, | ||
| path, &tx); | ||
| if (rc < 0) | ||
| return rc; | ||
| rc = reject_unsupported_fuse_path_op(&tx); | ||
| if (path_translate_at(dirfd, path, | ||
| (flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW | ||
| : PATH_TR_NONE, | ||
| &tx) < 0) | ||
| return linux_errno(); | ||
| int64_t rc = reject_unsupported_fuse_path_op(&tx); | ||
| if (rc != INT64_MIN) | ||
| return rc; | ||
|
|
||
|
|
@@ -2479,16 +2503,54 @@ int64_t sys_fchownat(guest_t *g, | |
| int flags) | ||
| { | ||
| char path[LINUX_PATH_MAX]; | ||
| if (!validate_at_flags(flags, LINUX_AT_SYMLINK_NOFOLLOW)) | ||
| if (!validate_at_flags(flags, | ||
| LINUX_AT_SYMLINK_NOFOLLOW | LINUX_AT_EMPTY_PATH)) | ||
| return -LINUX_EINVAL; | ||
| if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) | ||
| return -LINUX_EFAULT; | ||
|
|
||
| /* AT_EMPTY_PATH with an empty path chowns dirfd itself rather than a name | ||
| * beneath it. This is the only way to chown an O_PATH fd (plain fchown() | ||
| * rejects FD_PATH, matching Linux's EBADF there), so unlike the other | ||
| * *at() flag validation here it has to actually be handled, not just | ||
| * accepted. dirfd == AT_FDCWD resolves to the current directory, mirroring | ||
| * stat_at_path's identical AT_EMPTY_PATH branch in fs-stat.c. | ||
| */ | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TOCTOU on the AT_FDCWD overlay update. Suggested fix: open "." once and fchown(fd)+fstat(fd) on that single descriptor, like the dirfd branch. |
||
| int saved_errno = errno; | ||
| struct stat host_st; | ||
| const struct stat *st_ptr = | ||
| fstatat(AT_FDCWD, ".", &host_st, mac_flags) == 0 ? &host_st | ||
| : NULL; | ||
| errno = saved_errno; | ||
| return chown_result(host_rc, st_ptr, owner, group); | ||
| } | ||
|
|
||
| host_fd_ref_t ref; | ||
| if (host_dirfd_ref_open(dirfd, &ref) < 0) | ||
| return -LINUX_EBADF; | ||
|
|
||
| int host_rc = fchown(ref.fd, owner, group); | ||
| int saved_errno = errno; | ||
| struct stat host_st; | ||
| const struct stat *st_ptr = | ||
| fstat(ref.fd, &host_st) == 0 ? &host_st : NULL; | ||
| errno = saved_errno; | ||
| int64_t out = chown_result(host_rc, st_ptr, owner, group); | ||
| host_fd_ref_close(&ref); | ||
| return out; | ||
| } | ||
|
|
||
| path_translation_t tx; | ||
| int64_t rc = read_translated_path( | ||
| g, dirfd, path_gva, | ||
| (flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE, | ||
| path, &tx); | ||
| if (rc < 0) | ||
| return rc; | ||
| rc = reject_unsupported_fuse_path_op(&tx); | ||
| if (path_translate_at(dirfd, path, | ||
| (flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW | ||
| : PATH_TR_NONE, | ||
| &tx) < 0) | ||
| return linux_errno(); | ||
| int64_t rc = reject_unsupported_fuse_path_op(&tx); | ||
| if (rc != INT64_MIN) | ||
| return rc; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| /* | ||
| * fchmodat AT_EMPTY_PATH tests | ||
| * | ||
| * Copyright 2026 elfuse contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * fchmodat(fd, "", mode, AT_EMPTY_PATH) chmods fd itself instead of a name | ||
| * beneath it. This is the only way to chmod an O_PATH descriptor -- plain | ||
| * fchmod() rejects FD_PATH with EBADF, matching Linux -- and AT_FDCWD | ||
| * resolves to the current directory rather than being an error. | ||
| * | ||
| * The classic fchmodat(2) syscall predates any flags argument, so libc's | ||
| * fchmodat() wrapper (glibc and musl both) rejects any flag other than | ||
| * AT_SYMLINK_NOFOLLOW in userspace without ever making a syscall. Reaching | ||
| * AT_EMPTY_PATH support requires the flags-aware fchmodat2(2) (Linux 6.6), | ||
| * invoked here directly via syscall() since libc has no wrapper for it yet. | ||
| */ | ||
|
|
||
| #include <errno.h> | ||
| #include <fcntl.h> | ||
| #include <stdio.h> | ||
| #include <sys/stat.h> | ||
| #include <sys/syscall.h> | ||
| #include <unistd.h> | ||
|
|
||
| #include "test-harness.h" | ||
|
|
||
| #ifndef O_PATH | ||
| #define O_PATH 010000000 | ||
| #endif | ||
| #ifndef AT_EMPTY_PATH | ||
| #define AT_EMPTY_PATH 0x1000 | ||
| #endif | ||
| #ifndef SYS_fchmodat2 | ||
| #define SYS_fchmodat2 452 | ||
| #endif | ||
|
|
||
| static int fchmodat2(int dirfd, const char *path, mode_t mode, int flags) | ||
| { | ||
| return (int) syscall(SYS_fchmodat2, dirfd, path, (long) mode, (long) flags); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No ENOSYS skip guard for fchmodat2. This issues raw |
||
| } | ||
|
|
||
| int passes = 0, fails = 0; | ||
|
|
||
| static char tmp_file[256]; | ||
| static char tmp_dir[256]; | ||
|
|
||
| static int setup_fixtures(void) | ||
| { | ||
| snprintf(tmp_file, sizeof(tmp_file), "/tmp/elfuse-fchmodat-empty-%d.txt", | ||
| (int) getpid()); | ||
| int fd = open(tmp_file, O_WRONLY | O_CREAT | O_TRUNC, 0644); | ||
| if (fd < 0) { | ||
| perror("setup: open tmp_file"); | ||
| return -1; | ||
| } | ||
| close(fd); | ||
|
|
||
| /* The AT_FDCWD case below needs a writable cwd; the process's inherited | ||
| * cwd may not be (e.g. a read-only rootfs in a VM test image). | ||
| */ | ||
| snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/elfuse-fchmodat-empty-dir-%d", | ||
| (int) getpid()); | ||
| if (mkdir(tmp_dir, 0755) < 0) { | ||
| perror("setup: mkdir tmp_dir"); | ||
| return -1; | ||
| } | ||
| if (chdir(tmp_dir) < 0) { | ||
| perror("setup: chdir tmp_dir"); | ||
| return -1; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| static void teardown_fixtures(void) | ||
| { | ||
| unlink(tmp_file); | ||
| chdir("/tmp"); | ||
| rmdir(tmp_dir); | ||
| } | ||
|
|
||
| static void test_empty_path_via_opath_fd(void) | ||
| { | ||
| TEST("fchmodat(O_PATH fd, \"\", AT_EMPTY_PATH) chmods the file"); | ||
| int fd = open(tmp_file, O_PATH); | ||
| if (fd < 0) { | ||
| FAIL("open O_PATH"); | ||
| return; | ||
| } | ||
| if (fchmodat2(fd, "", 0600, AT_EMPTY_PATH) != 0) { | ||
| FAIL("fchmodat"); | ||
| close(fd); | ||
| return; | ||
| } | ||
| struct stat st; | ||
| int ok = fstat(fd, &st) == 0 && (st.st_mode & 0777) == 0600; | ||
| close(fd); | ||
| EXPECT_TRUE(ok, "mode mismatch"); | ||
| } | ||
|
|
||
| static void test_plain_fchmod_still_rejects_opath(void) | ||
| { | ||
| TEST("fchmod(O_PATH fd) still returns EBADF"); | ||
| int fd = open(tmp_file, O_PATH); | ||
| if (fd < 0) { | ||
| FAIL("open O_PATH"); | ||
| return; | ||
| } | ||
| int rc = fchmod(fd, 0644); | ||
| int err = errno; | ||
| close(fd); | ||
| EXPECT_TRUE(rc == -1 && err == EBADF, "fchmod should reject O_PATH"); | ||
| } | ||
|
|
||
| static void test_empty_path_via_regular_fd(void) | ||
| { | ||
| TEST("fchmodat(regular fd, \"\", AT_EMPTY_PATH) chmods the file"); | ||
| int fd = open(tmp_file, O_RDONLY); | ||
| if (fd < 0) { | ||
| FAIL("open"); | ||
| return; | ||
| } | ||
| if (fchmodat2(fd, "", 0640, AT_EMPTY_PATH) != 0) { | ||
| FAIL("fchmodat"); | ||
| close(fd); | ||
| return; | ||
| } | ||
| struct stat st; | ||
| int ok = fstat(fd, &st) == 0 && (st.st_mode & 0777) == 0640; | ||
| close(fd); | ||
| EXPECT_TRUE(ok, "mode mismatch"); | ||
| } | ||
|
|
||
| static void test_empty_path_at_fdcwd_targets_cwd(void) | ||
| { | ||
| TEST("fchmodat(AT_FDCWD, \"\", AT_EMPTY_PATH) chmods cwd"); | ||
| if (fchmodat2(AT_FDCWD, "", 0700, AT_EMPTY_PATH) != 0) { | ||
| FAIL("fchmodat"); | ||
| return; | ||
| } | ||
| struct stat st; | ||
| int ok = stat(".", &st) == 0 && (st.st_mode & 0777) == 0700; | ||
| /* Restore a mode that still lets teardown_fixtures rmdir it. */ | ||
| chmod(".", 0755); | ||
| EXPECT_TRUE(ok, "mode mismatch"); | ||
| } | ||
|
|
||
| int main(void) | ||
| { | ||
| printf("test-fchmodat-empty-path: AT_EMPTY_PATH chmod-by-fd semantics\n"); | ||
|
|
||
| if (setup_fixtures() < 0) { | ||
| printf("test-fchmodat-empty-path: fixture setup failed\n"); | ||
| return 1; | ||
| } | ||
|
|
||
| test_empty_path_via_opath_fd(); | ||
| test_plain_fchmod_still_rejects_opath(); | ||
| test_empty_path_via_regular_fd(); | ||
| test_empty_path_at_fdcwd_targets_cwd(); | ||
|
|
||
| teardown_fixtures(); | ||
|
|
||
| SUMMARY("test-fchmodat-empty-path"); | ||
| return fails == 0 ? 0 : 1; | ||
| } | ||
There was a problem hiding this comment.
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:
dirfdinside a FUSE mount hashost_fd == -1(fuse.c:1893), sohost_dirfd_ref_openreturns EBADF here, whereas the named path returns ENOSYS.fchmodat(".", ...)directly, mutating the host backing path instead of returning ENOSYS.Suggested fix: snapshot
dirfdfirst and return-LINUX_ENOSYSforFD_FUSE_*to match reject_unsupported_fuse_path_op.