diff --git a/src/syscall/fs.c b/src/syscall/fs.c index ef17592..80b5aeb 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -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) + 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); + 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; diff --git a/tests/test-fchmodat-empty-path.c b/tests/test-fchmodat-empty-path.c new file mode 100644 index 0000000..00d8b43 --- /dev/null +++ b/tests/test-fchmodat-empty-path.c @@ -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 +#include +#include +#include +#include +#include + +#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); +} + +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; +} diff --git a/tests/test-fchownat-empty-path.c b/tests/test-fchownat-empty-path.c new file mode 100644 index 0000000..75a88be --- /dev/null +++ b/tests/test-fchownat-empty-path.c @@ -0,0 +1,149 @@ +/* + * fchownat AT_EMPTY_PATH tests + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * fchownat(fd, "", uid, gid, AT_EMPTY_PATH) chowns fd itself instead of a name + * beneath it. This is the only way to chown an O_PATH descriptor -- plain + * fchown() rejects FD_PATH with EBADF, matching Linux -- and AT_FDCWD resolves + * to the current directory rather than being an error. + */ + +#include +#include +#include +#include +#include + +#include "test-harness.h" + +#ifndef O_PATH +#define O_PATH 010000000 +#endif +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif + +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-fchownat-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-fchownat-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("fchownat(O_PATH fd, \"\", AT_EMPTY_PATH) chowns the file"); + int fd = open(tmp_file, O_PATH); + if (fd < 0) { + FAIL("open O_PATH"); + return; + } + if (fchownat(fd, "", 4001, 4002, AT_EMPTY_PATH) != 0) { + FAIL("fchownat"); + close(fd); + return; + } + struct stat st; + int ok = fstat(fd, &st) == 0 && st.st_uid == 4001 && st.st_gid == 4002; + close(fd); + EXPECT_TRUE(ok, "uid/gid mismatch"); +} + +static void test_plain_fchown_still_rejects_opath(void) +{ + TEST("fchown(O_PATH fd) still returns EBADF"); + int fd = open(tmp_file, O_PATH); + if (fd < 0) { + FAIL("open O_PATH"); + return; + } + int rc = fchown(fd, 0, 0); + int err = errno; + close(fd); + EXPECT_TRUE(rc == -1 && err == EBADF, "fchown should reject O_PATH"); +} + +static void test_empty_path_via_regular_fd(void) +{ + TEST("fchownat(regular fd, \"\", AT_EMPTY_PATH) chowns the file"); + int fd = open(tmp_file, O_RDONLY); + if (fd < 0) { + FAIL("open"); + return; + } + if (fchownat(fd, "", 5001, 5002, AT_EMPTY_PATH) != 0) { + FAIL("fchownat"); + close(fd); + return; + } + struct stat st; + int ok = fstat(fd, &st) == 0 && st.st_uid == 5001 && st.st_gid == 5002; + close(fd); + EXPECT_TRUE(ok, "uid/gid mismatch"); +} + +static void test_empty_path_at_fdcwd_targets_cwd(void) +{ + TEST("fchownat(AT_FDCWD, \"\", AT_EMPTY_PATH) chowns cwd"); + if (fchownat(AT_FDCWD, "", 6001, 6002, AT_EMPTY_PATH) != 0) { + FAIL("fchownat"); + return; + } + struct stat st; + int ok = stat(".", &st) == 0 && st.st_uid == 6001 && st.st_gid == 6002; + EXPECT_TRUE(ok, "uid/gid mismatch"); +} + +int main(void) +{ + printf("test-fchownat-empty-path: AT_EMPTY_PATH chown-by-fd semantics\n"); + + if (setup_fixtures() < 0) { + printf("test-fchownat-empty-path: fixture setup failed\n"); + return 1; + } + + test_empty_path_via_opath_fd(); + test_plain_fchown_still_rejects_opath(); + test_empty_path_via_regular_fd(); + test_empty_path_at_fdcwd_targets_cwd(); + + teardown_fixtures(); + + SUMMARY("test-fchownat-empty-path"); + return fails == 0 ? 0 : 1; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index e4a937d..bc4e629 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -778,6 +778,12 @@ run_unit_tests() printf "\nVirtual chown overlay\n" test_rc "$runner" "test-chown-overlay" 0 "$bindir/test-chown-overlay" + printf "\nfchownat AT_EMPTY_PATH\n" + test_rc "$runner" "test-fchownat-empty-path" 0 "$bindir/test-fchownat-empty-path" + + printf "\nfchmodat2 AT_EMPTY_PATH\n" + test_rc "$runner" "test-fchmodat-empty-path" 0 "$bindir/test-fchmodat-empty-path" + printf "\nX11 raw protocol\n" test_check "$runner" "test-x11" "0 failed" "$bindir/test-x11" } @@ -1208,8 +1214,8 @@ run_suite() # observed counts diverge. apple-unknown is the fallback row for SoC strings the # detector does not recognize yet. EXPECTED_BASELINES=( - "elfuse-aarch64|235|0" - "qemu-aarch64|215|0" + "elfuse-aarch64|237|0" + "qemu-aarch64|217|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0"