diff --git a/mk/tests.mk b/mk/tests.mk index fd412ff..b989deb 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -9,7 +9,8 @@ test-rosetta-glibc test-rosetta-all bench-rosetta \ test-matrix test-matrix-elfuse-aarch64 test-matrix-qemu-aarch64 \ test-full test-multi-vcpu test-rwx test-sysroot-rename \ - test-case-collision test-case-collision-fallback test-sysroot-create-paths \ + test-case-collision test-case-collision-fallback test-getdents64-overlong \ + test-sysroot-create-paths \ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir perf @@ -48,6 +49,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-busybox @printf "\n$(BLUE)━━━ sysroot procfs exec validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-procfs-exec + @printf "\n$(BLUE)━━━ getdents64 overlong-UTF-8 dirent skip ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-getdents64-overlong @printf "\n$(BLUE)━━━ Alpine sysroot FUSE validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-fuse-alpine @printf "\n$(BLUE)━━━ timeout=0 validation ━━━$(RESET)\n" @@ -117,6 +120,26 @@ test-case-collision-fallback: $(ELFUSE_BIN) $(BUILD_DIR)/test-case-collision trap 'rm -rf "$$tmpdir"' EXIT; \ $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-case-collision +# Build APFS-side dirents whose UTF-8 byte length exceeds Linux +# NAME_MAX (255). 89 copies of U+3042 (3-byte UTF-8) plus a 1-byte +# ASCII tag = 268 bytes per name; the guest cannot forge this via +# openat (NAME_MAX is enforced), so the harness stages it host-side +# and the guest scans the listing. Five overlong files plus one +# normal entry: with a one-entry-per-call buffer on the guest side, +# any APFS hash ordering puts an overlong entry in a position where +# pre-fix code returned -ENAMETOOLONG to userspace. +test-getdents64-overlong: $(ELFUSE_BIN) $(BUILD_DIR)/test-getdents64-overlong + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + mkdir -p "$$tmpdir/fixture"; \ + : > "$$tmpdir/fixture/expected.txt"; \ + for tag in a b c d e; do \ + overlong=$$(printf '\343\201\202%.0s' $$(seq 1 89))$$tag; \ + : > "$$tmpdir/fixture/$$overlong"; \ + done; \ + $(ELFUSE_BIN) $(BUILD_DIR)/test-getdents64-overlong "$$tmpdir/fixture" + test-sysroot-create-paths: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-create-paths @set -e; \ tmpdir=$$(mktemp -d); \ diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 51264be..baa08a1 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -1107,8 +1107,35 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) sizeof(guest_name)); if (name_rc > 0) continue; - if (name_rc < 0) + if (name_rc < 0) { + /* macOS APFS accepts UTF-8 filenames whose byte length exceeds + * Linux NAME_MAX (255). A guest libc cannot represent such a + * name in its 256-byte dirent buffer at all, so elfuse silently + * skips the unrepresentable entry and keeps the rest of the + * stream intact. This is an elfuse compatibility policy, not + * Linux kernel behavior: real getdents64 has no equivalent + * skip path because Linux NAME_MAX is enforced at the + * filesystem layer, so no oversize entry ever reaches + * verify_dirent_name. Aborting the whole stream the way the + * pre-fix code did truncated ls / find / coreutils listings + * against APFS-mounted source trees. Skip on ENAMETOOLONG; + * keep the existing partial-return path for any other + * translation failure so genuine errors are not silently + * dropped. + */ + if (errno == ENAMETOOLONG) { + static bool overlong_warned; + if (!__atomic_exchange_n(&overlong_warned, true, + __ATOMIC_RELAXED)) + log_warn( + "getdents64: skipping host dirent whose name " + "exceeds Linux NAME_MAX (%u); first hit was " + "%zu bytes on fd %d", + NAME_MAX, strlen(de->d_name), fd); + continue; + } return guest_pos > 0 ? (int64_t) guest_pos : linux_errno(); + } size_t name_len = strlen(guest_name); /* Linux dirent64: 19-byte header + name + null, padded to 8 */ diff --git a/tests/test-getdents64-overlong.c b/tests/test-getdents64-overlong.c new file mode 100644 index 0000000..d230b4e --- /dev/null +++ b/tests/test-getdents64-overlong.c @@ -0,0 +1,170 @@ +/* getdents64 overlong-UTF-8 dirent skip regression + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * macOS APFS lets filenames exceed Linux NAME_MAX (255 bytes) on the + * UTF-8 byte axis: ~90 CJK codepoints already crosses the cap while + * staying under APFS's per-component character limit. The guest + * cannot create such a name through Linux syscalls (NAME_MAX is + * enforced at openat), so the surrounding harness builds the fixture + * host-side and passes the directory path as argv[1]. + * + * Pre-fix behavior: sys_getdents64 aborted the whole stream with + * ENAMETOOLONG the first time path_translate_dirent_name reported an + * oversize entry, truncating ls / find / coreutils listings. + * Post-fix: the overlong entry is skipped and the rest of the stream + * is delivered, matching what real Linux does on the same input. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +#ifndef SYS_getdents64 +#define SYS_getdents64 61 +#endif + +int passes = 0, fails = 0; + +typedef struct { + unsigned long long d_ino; + long long d_off; + unsigned short d_reclen; + unsigned char d_type; + char d_name[]; +} linux_dirent64_t; + +static const char EXPECTED_NAME[] = "expected.txt"; + +/* Drain the directory via raw getdents64. Counts how many real entries + * (skipping "." and "..") show up and whether EXPECTED_NAME is seen. + * Returns -errno on the first non-EOF failure so the caller can tell a + * mid-stream ENAMETOOLONG from an empty directory. + * + * The buffer is sized just past a single small dirent so each call + * returns at most one entry. With multiple overlong files in the + * fixture, this guarantees at least one call starts fresh (guest_pos + * == 0) on an overlong entry, which is the exact condition under + * which the pre-fix code returns -ENAMETOOLONG to userspace and + * truncates the listing for ls / find. Larger buffers can mask the + * bug because APFS hash order may bury every overlong after a + * partial-return point. + */ +static int scan_directory(const char *path, + int *out_entries, + int *out_saw_expected) +{ + *out_entries = 0; + *out_saw_expected = 0; + + int fd = open(path, O_RDONLY | O_DIRECTORY); + if (fd < 0) + return -errno; + + /* 64 bytes caps each call at one entry for the visible names + * (reclen 24 for ".", 24 for "..", 32 for "expected.txt"; ". + .." + * could pack into 48, but five overlong files outnumber three + * visible normals so at least one call still starts fresh on an + * overlong entry with guest_pos == 0 -- the exact condition under + * which pre-fix sys_getdents64 returned -ENAMETOOLONG to userspace + * and truncated the listing). + */ + char buf[64]; + for (;;) { + long n = syscall(SYS_getdents64, fd, buf, sizeof(buf)); + if (n < 0) { + int err = errno; + close(fd); + return -err; + } + if (n == 0) + break; + + /* Validate the binary ABI strictly: an unterminated d_name or a + * forged d_reclen could otherwise let strcmp walk off the buffer. + * Header is 19 bytes; max valid record fits in n-off. + */ + for (long off = 0; off < n;) { + linux_dirent64_t *de = (linux_dirent64_t *) (buf + off); + if (de->d_reclen < 19 || de->d_reclen > (unsigned) (n - off)) { + close(fd); + return -EIO; + } + size_t name_cap = (size_t) de->d_reclen - 19; + if (!memchr(de->d_name, '\0', name_cap)) { + close(fd); + return -EIO; + } + const char *name = de->d_name; + if (strcmp(name, ".") != 0 && strcmp(name, "..") != 0) { + (*out_entries)++; + if (strcmp(name, EXPECTED_NAME) == 0) + *out_saw_expected = 1; + } + off += de->d_reclen; + } + } + + close(fd); + return 0; +} + +int main(int argc, char **argv) +{ + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + const char *dir = argv[1]; + printf("test-getdents64-overlong: scanning %s\n", dir); + + int entries = 0, saw_expected = 0; + int rc = scan_directory(dir, &entries, &saw_expected); + + TEST("getdents64 does not abort with ENAMETOOLONG"); + if (rc == -ENAMETOOLONG) { + errno = ENAMETOOLONG; + FAIL("stream aborted on overlong entry"); + } else if (rc < 0) { + errno = -rc; + FAIL("getdents64 returned unexpected error"); + } else { + PASS(); + } + + TEST("normal entry survives the scan"); + if (rc < 0) { + errno = -rc; + FAIL("scan failed before reaching expected entry"); + } else if (!saw_expected) { + FAIL("expected.txt missing from listing"); + } else { + PASS(); + } + + TEST("listing has only the normal entry"); + /* The overlong file is present on disk but must be silently + * skipped, so the visible-entry count is exactly 1. + */ + if (rc < 0) { + errno = -rc; + FAIL("scan failed before count check"); + } else if (entries != 1) { + fprintf(stderr, " observed %d visible entries\n", entries); + FAIL("unexpected visible entry count"); + } else { + PASS(); + } + + SUMMARY("test-getdents64-overlong"); + return fails == 0 ? 0 : 1; +}