fstat() of a pipe or socket fails with ENOTDIR under a sysroot
Summary
fstatat(fd, "", &st, AT_EMPTY_PATH) returns ENOTDIR for any descriptor that
has no host path behind it — a pipe, a socket, an eventfd. Since glibc 2.33 that
is how fstat(fd) is spelled, so on a sysroot with such a glibc, plain
fstat() on a pipe fails.
The empty path names the descriptor itself and resolves nothing, but
stat_at_path() runs path_translate_at() before it looks at AT_EMPTY_PATH.
That resolver measures the (relative, empty) name against dirfd, and
dirfd_guest_base_path() correctly reports ENOTDIR for a descriptor that is
not a directory — correct for a name, wrong for the empty path.
The most visible casualty is coreutils cat, which fstats its stdout as its
first action, so anything | cat dies with
cat: standard output: Not a directory. That takes down dpkg/apt flows that
pipe a tar stream through cat.
Environment
- elfuse
b1aac9a (reproduced on a clean checkout of that commit)
- macOS 26 (Darwin 25.6.0), Apple silicon
- Affected sysroot: Debian 12 (bookworm) aarch64, glibc 2.36
- Unaffected sysroot: Ubuntu 26.04 aarch64, glibc 2.43
Reproduction
elfuse --sysroot DEBIAN12_ROOT /bin/sh -c 'cat /etc/hostname | cat'
Actual:
cat: standard output: Not a directory
cat: standard output: Not a directory
rc=1
Expected: the hostname, as on the host.
The same command against an Ubuntu 26.04 (glibc 2.43) sysroot with the same
elfuse binary prints the hostname — that glibc issues __NR_fstat (syscall 80),
which lands in sys_fstat() and is not affected.
Another one-liner on the Debian sysroot, same cause:
elfuse --sysroot DEBIAN12_ROOT /bin/sh -c 'stat -c %F /proc/self/fd/1 | cat'
Trace
--verbose on the failing run:
syscall 79@0x2000ed07c(0x1, 0x2001690a0, 0x7ffef08, 0x1000, 0x0, 0x0)
-> -20 (0xffffffffffffffec)
syscall 79 = newfstatat, dirfd = 1 (the pipe), flags = 0x1000 =
AT_EMPTY_PATH, result = -ENOTDIR.
The failing cat call site is:
/* coreutils src/cat.c */
if (fstat (STDOUT_FILENO, &stat_buf) < 0)
error (EXIT_FAILURE, errno, _("standard output"));
which is where the message text comes from.
Root cause
stat_at_path() (src/syscall/fs-stat.c) reads the path and then immediately
calls path_translate_at(); its AT_EMPTY_PATH branch is further down, after
translation:
if (guest_read_path(...) < 0)
return -LINUX_EFAULT;
if (pathp[0] == '/' && fuse_path_matches_mount(pathp)) { ... }
path_translation_t tx;
if (path_translate_at(dirfd, pathp, ..., &tx) < 0)
return linux_errno(); /* <-- ENOTDIR comes from here */
...
if ((flags & LINUX_AT_EMPTY_PATH) && pathp[0] == '\0') { ... }
Inside path_translate_at() the empty path is a relative path
(guest_path[0] != '/'), so with a sysroot active it reaches
path_check_relative_sysroot_containment() →
dirfd_reconstruct_abs_path() → dirfd_guest_base_path()
(src/syscall/path.c), which ends in:
/* fd_snapshot already proved dirfd is open, so a valid-but-wrong-type fd
* (pipe, socket, epoll, ...) belongs here, not in the "bad fd" case: Linux
* resolves a relative path against such a dirfd with ENOTDIR, reserving
* EBADF for a closed or out-of-range descriptor.
*/
if (snap.type != FD_DIR) {
errno = ENOTDIR;
return -1;
}
That reasoning is right for a name, but the empty path is not a lookup, so the
whole translation is a false negative here — its result is not even used by the
AT_EMPTY_PATH branch, which only ever fstats the descriptor.
Note the failing branch is gated on proc_get_sysroot(), so this appears to
need --sysroot (which is the normal way to run a distro rootfs).
sys_fchmodat() and sys_fchownat() already handle this correctly — both
short-circuit AT_EMPTY_PATH + empty path before translation, with a comment
explaining why. stat_at_path() is the odd one out. Since sys_statx() shares
stat_at_path(), statx(fd, "", AT_EMPTY_PATH, ...) has the same bug.
Impact
Anything that fstats a non-directory descriptor with no host path, on a sysroot
whose glibc is in the range that spells fstat() as fstatat(..., AT_EMPTY_PATH)
(2.33 through at least 2.36; 2.43 issues __NR_fstat again). In practice:
cat into a pipe fails, which breaks dpkg-deb --fsys-tarfile | dpkg style
package unpacking, and anything else that stats stdout/stdin when it is a pipe.
Suggested fix
Answer AT_EMPTY_PATH + empty path from the descriptor before any path
translation, the way sys_fchmodat/sys_fchownat do. FUSE descriptors go
through the fd-based shim (as sys_fstat does), O_PATH descriptors through the
/proc stat intercept, everything else through fstat(). AT_FDCWD keeps the
existing behavior — the cwd always has a base path, so it was never affected.
Patch below is against b1aac9a; it is clang-format clean. I verified it by
hand on a Debian 12 aarch64 sysroot (the reproduction above passes, stat -c %F /proc/self/fd/1 reports fifo, and a full dpkg -i / apt-get install run
completes), but I could not run the guest test lanes — they need the aarch64
cross toolchain at /opt/toolchain, which I do not have set up. Happy to open it
as a PR if you would like it in that form.
--- a/src/syscall/fs-stat.c
+++ b/src/syscall/fs-stat.c
@@ -162,6 +162,52 @@ static void translate_statfs(const struct statfs *mac, linux_statfs_t *lin)
lin->f_frsize = mac->f_bsize;
}
+/* AT_EMPTY_PATH with an empty path names the descriptor itself, so nothing is
+ * resolved against it. That has to be answered before path_translate_at runs:
+ * the resolver measures a relative name against dirfd and owes ENOTDIR for a
+ * descriptor that is not a directory, which is right for a name but wrong for
+ * the empty path -- it fails fstat() on a pipe, a socket, or any other fd with
+ * no host path behind it, since glibc since 2.33 spells fstat() as
+ * fstatat(fd, "", AT_EMPTY_PATH). sys_fchmodat and sys_fchownat short-circuit
+ * the same way and for the same reason.
+ *
+ * FUSE descriptors go through the fd-based shim rather than a path, matching
+ * sys_fstat: the emulation layer, not the host file, is what answers for them.
+ *
+ * Returns 0 on success or a negative Linux errno.
+ */
+static int64_t stat_empty_path_fd(int dirfd, struct stat *mac_st)
+{
+ int frc = fuse_fstat_fd(dirfd, mac_st);
+ if (frc == 0)
+ return 0;
+ if (frc != -LINUX_EBADF)
+ return frc;
+
+ fd_entry_t snap;
+ host_fd_ref_t ref = {.fd = fd_snapshot_and_dup(dirfd, &snap),
+ .owned = true};
+ if (ref.fd < 0)
+ return -LINUX_EBADF;
+
+ int64_t rc = 0;
+ if (snap.type == FD_PATH && snap.proc_path[0] != '\0') {
+ int intercepted = proc_intercept_stat(snap.proc_path, mac_st);
+ if (intercepted == 0)
+ goto done;
+ if (intercepted == -1) {
+ rc = linux_errno();
+ goto done;
+ }
+ }
+ if (fstat(ref.fd, mac_st) < 0)
+ rc = linux_errno();
+
+done:
+ host_fd_ref_close(&ref);
+ return rc;
+}
+
/* Resolve the directory + path arguments of a *at-style stat operation and fill
* *mac_st via the appropriate host call (proc intercept where applicable).
* Shared by sys_newfstatat and sys_statx; the caller copies the result into the
@@ -192,6 +238,10 @@ static int64_t stat_at_path(guest_t *g,
sizeof(path), &pathp) < 0)
return -LINUX_EFAULT;
+ if ((flags & LINUX_AT_EMPTY_PATH) && pathp[0] == '\0' &&
+ dirfd != LINUX_AT_FDCWD)
+ return stat_empty_path_fd(dirfd, mac_st);
+
if (pathp[0] == '/' && fuse_path_matches_mount(pathp)) {
int frc = fuse_stat_path(pathp, mac_st, flags);
if (frc < 0)
@@ -224,36 +274,14 @@ static int64_t stat_at_path(guest_t *g,
host_fd_ref_t dir_ref = {.fd = -1, .owned = false};
if ((flags & LINUX_AT_EMPTY_PATH) && pathp[0] == '\0') {
/* Linux: AT_EMPTY_PATH with dirfd == AT_FDCWD operates on the current
- * working directory.
+ * working directory. Every other descriptor was already answered by
+ * stat_empty_path_fd() above.
*/
- if (dirfd == LINUX_AT_FDCWD) {
- dir_ref.fd = AT_FDCWD;
- int mac_flags = translate_at_flags(flags);
- if (fstatat(AT_FDCWD, ".", mac_st, mac_flags) < 0) {
- rc = linux_errno();
- goto done;
- }
- } else {
- fd_entry_t snap;
- dir_ref.fd = fd_snapshot_and_dup(dirfd, &snap);
- dir_ref.owned = true;
- if (dir_ref.fd < 0) {
- rc = -LINUX_EBADF;
- goto done;
- }
- if (snap.type == FD_PATH && snap.proc_path[0] != '\0') {
- int intercepted = proc_intercept_stat(snap.proc_path, mac_st);
- if (intercepted == 0)
- goto done;
- if (intercepted == -1) {
- rc = linux_errno();
- goto done;
- }
- }
- if (fstat(dir_ref.fd, mac_st) < 0) {
- rc = linux_errno();
- goto done;
- }
+ dir_ref.fd = AT_FDCWD;
+ int mac_flags = translate_at_flags(flags);
+ if (fstatat(AT_FDCWD, ".", mac_st, mac_flags) < 0) {
+ rc = linux_errno();
+ goto done;
}
} else {
if (host_dirfd_ref_open(dirfd, &dir_ref) < 0)
Suggested regression test
A guest test in the shape of tests/test-fchmodat-empty-path.c would cover it:
for a pipe read end, a pipe write end, a socketpair fd and an eventfd, assert
that fstatat(fd, "", &st, AT_EMPTY_PATH) succeeds and reports the right
S_IFMT, and that plain fstat(fd) agrees. Also assert statx(fd, "", AT_EMPTY_PATH, ...) succeeds on the same descriptors, since it shares the code
path. Worth running under --sysroot, since the failing branch is gated on a
sysroot being active.
fstat() of a pipe or socket fails with ENOTDIR under a sysroot
Summary
fstatat(fd, "", &st, AT_EMPTY_PATH)returnsENOTDIRfor any descriptor thathas no host path behind it — a pipe, a socket, an eventfd. Since glibc 2.33 that
is how
fstat(fd)is spelled, so on a sysroot with such a glibc, plainfstat()on a pipe fails.The empty path names the descriptor itself and resolves nothing, but
stat_at_path()runspath_translate_at()before it looks atAT_EMPTY_PATH.That resolver measures the (relative, empty) name against
dirfd, anddirfd_guest_base_path()correctly reportsENOTDIRfor a descriptor that isnot a directory — correct for a name, wrong for the empty path.
The most visible casualty is coreutils
cat, whichfstats its stdout as itsfirst action, so
anything | catdies withcat: standard output: Not a directory. That takes downdpkg/aptflows thatpipe a tar stream through
cat.Environment
b1aac9a(reproduced on a clean checkout of that commit)Reproduction
elfuse --sysroot DEBIAN12_ROOT /bin/sh -c 'cat /etc/hostname | cat'Actual:
Expected: the hostname, as on the host.
The same command against an Ubuntu 26.04 (glibc 2.43) sysroot with the same
elfuse binary prints the hostname — that glibc issues
__NR_fstat(syscall 80),which lands in
sys_fstat()and is not affected.Another one-liner on the Debian sysroot, same cause:
elfuse --sysroot DEBIAN12_ROOT /bin/sh -c 'stat -c %F /proc/self/fd/1 | cat'Trace
--verboseon the failing run:syscall 79 =
newfstatat, dirfd = 1 (the pipe), flags =0x1000=AT_EMPTY_PATH, result =-ENOTDIR.The failing
catcall site is:which is where the message text comes from.
Root cause
stat_at_path()(src/syscall/fs-stat.c) reads the path and then immediatelycalls
path_translate_at(); itsAT_EMPTY_PATHbranch is further down, aftertranslation:
Inside
path_translate_at()the empty path is a relative path(
guest_path[0] != '/'), so with a sysroot active it reachespath_check_relative_sysroot_containment()→dirfd_reconstruct_abs_path()→dirfd_guest_base_path()(
src/syscall/path.c), which ends in:That reasoning is right for a name, but the empty path is not a lookup, so the
whole translation is a false negative here — its result is not even used by the
AT_EMPTY_PATHbranch, which only everfstats the descriptor.Note the failing branch is gated on
proc_get_sysroot(), so this appears toneed
--sysroot(which is the normal way to run a distro rootfs).sys_fchmodat()andsys_fchownat()already handle this correctly — bothshort-circuit
AT_EMPTY_PATH+ empty path before translation, with a commentexplaining why.
stat_at_path()is the odd one out. Sincesys_statx()sharesstat_at_path(),statx(fd, "", AT_EMPTY_PATH, ...)has the same bug.Impact
Anything that
fstats a non-directory descriptor with no host path, on a sysrootwhose glibc is in the range that spells
fstat()asfstatat(..., AT_EMPTY_PATH)(2.33 through at least 2.36; 2.43 issues
__NR_fstatagain). In practice:catinto a pipe fails, which breaksdpkg-deb --fsys-tarfile | dpkgstylepackage unpacking, and anything else that stats stdout/stdin when it is a pipe.
Suggested fix
Answer
AT_EMPTY_PATH+ empty path from the descriptor before any pathtranslation, the way
sys_fchmodat/sys_fchownatdo. FUSE descriptors gothrough the fd-based shim (as
sys_fstatdoes), O_PATH descriptors through the/procstat intercept, everything else throughfstat().AT_FDCWDkeeps theexisting behavior — the cwd always has a base path, so it was never affected.
Patch below is against
b1aac9a; it isclang-formatclean. I verified it byhand on a Debian 12 aarch64 sysroot (the reproduction above passes,
stat -c %F /proc/self/fd/1reportsfifo, and a fulldpkg -i/apt-get installruncompletes), but I could not run the guest test lanes — they need the aarch64
cross toolchain at
/opt/toolchain, which I do not have set up. Happy to open itas a PR if you would like it in that form.
Suggested regression test
A guest test in the shape of
tests/test-fchmodat-empty-path.cwould cover it:for a pipe read end, a pipe write end, a socketpair fd and an eventfd, assert
that
fstatat(fd, "", &st, AT_EMPTY_PATH)succeeds and reports the rightS_IFMT, and that plainfstat(fd)agrees. Also assertstatx(fd, "", AT_EMPTY_PATH, ...)succeeds on the same descriptors, since it shares the codepath. Worth running under
--sysroot, since the failing branch is gated on asysroot being active.