Skip to content

fstat() of a pipe or socket fails with ENOTDIR under a sysroot #322

Description

@doanbaotrung

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions