Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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); \
Expand Down
29 changes: 28 additions & 1 deletion src/syscall/fs.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
170 changes: 170 additions & 0 deletions tests/test-getdents64-overlong.c
Original file line number Diff line number Diff line change
@@ -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 <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

#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 <fixture-dir>\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;
}
Loading