Skip to content

Assert each syscall flag set and clear, and fix what it finds - #421

Open
xalestar wants to merge 30 commits into
sysprog21:mainfrom
xalestar:flag-matrix
Open

xalestar wants to merge 30 commits into
sysprog21:mainfrom
xalestar:flag-matrix

Conversation

@xalestar

@xalestar xalestar commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

A flag elfuse drops or mistranslates on the way to the host fails nothing today unless some test happens to depend on it. This adds a table of syscall flags with the answer Linux gives when each flag is set and when it is clear, a test that asserts both, and fixes for what the table found.

Part of #354 (see the section on it below). No issue exists for the test itself.

The test

tests/flag-matrix.tbl has one row per syscall and flag: the arguments to call it with, and the answer with the flag set and with it clear. scripts/gen-flag-matrix.py joins the rows against dispatch.tbl and writes build/flag-matrix-vectors.h, the same arrangement usbdev-ioctl-departed uses. tests/test-flag-matrix.c runs every row both ways through a raw syscall against a fixture directory it builds fresh for each run. It has no code for any one syscall. Both answers are asserted. Where they differ, a row passes only when the flag is what changes the result; 31 rows end in =, which records that Linux answers the same either way.

The qemu lane runs the binary with strict and is the judge of every recorded answer. 139 rows over 26 syscalls, 278 answers.

A row can carry one of two marks for elfuse:

  • pending: a difference still to be fixed. The mismatch is printed as KNOWN and does not fail the lane. Once the row passes, the lane fails until the mark is removed.
  • unsupported:ANSWER: a flag elfuse declines on purpose. ANSWER has to be one Linux documents for a kernel or filesystem without the feature.

One row is pending and one is unsupported at the head; both are described under "Left as they are".

Removing one translation at a time from src/syscall/translate.c fails the rows of the syscalls that use it and no others. Each of these was measured at least once on this branch: O_APPEND, O_NONBLOCK, O_EXCL, O_TRUNC, O_NOFOLLOW, O_CREAT, O_DIRECTORY, the O_PATH branch, O_SYNC/O_DSYNC, AT_REMOVEDIR, AT_SYMLINK_FOLLOW, and AT_SYMLINK_NOFOLLOW in both translators. Removing O_CLOEXEC fails nothing, because a guest cannot see whether the host open carried it: a guest execve stays in the host process and the fork helper is spawned with POSIX_SPAWN_CLOEXEC_DEFAULT. The table says so beside that row.

check-syscall-coverage.py fails when one of the 26 syscalls loses all of its rows, and when the table gains a syscall its list does not name.

The commits

30 commits. Five add the test or rows that already pass (3db8916, 36c20bc, ec2de4d, 800215c, 81abd03), one moves code with no change in behavior (cffa733), one is the audit (a8c562e), 21 change elfuse, and two correct a test and a row after review (476d4e4, 2c747be). Each fix brings the rows that hold it, so the test passes at every commit and no row is added as pending to be unmarked later.

Before After Commit
epoll_create1, inotify_init1, memfd_create, accept4 an undefined flag bit was accepted EINVAL d77b3b5
empty path, no AT_EMPTY_PATH: newfstatat, statx, fchownat, fchmodat, openat, renameat2, and the calls that read a path through read_translated_path ENOTDIR when dirfd is not a directory ENOENT b99ceb4
statx with both sync flags 0 EINVAL 382b954
unlink of a directory EPERM EISDIR, except the three cases where Linux also says EPERM aad176d
pipe2(O_DIRECT) F_GETFL on the write end did not report it reports it, as F_SETFL already did 23dca1e
openat with O_TMPFILE|O_RDONLY opened the directory and returned it EINVAL c6919ce
mmap with neither MAP_SHARED nor MAP_PRIVATE, or anonymous MAP_SHARED_VALIDATE mapped EINVAL fd3da6a
mmap under MAP_SHARED_VALIDATE with MAP_SYNC, MAP_FIXED_NOREPLACE, or any bit from 31 up mapped EOPNOTSUPP fd3da6a
mmap with a bad descriptor and a bad type EINVAL EBADF fd3da6a
mprotect with an undefined bit, PROT_GROWSUP, or PROT_GROWSDOWN off the stack 0 EINVAL c3b78d9
mprotect with PROT_BTI or PROT_MTE 0 EINVAL, as on a CPU without the feature; the guest is shown neither c3b78d9
mprotect with PROT_GROWSDOWN on the stack changed only the range given extends down to the first page above the guard c3b78d9
a fresh non-fixed PROT_READ mapping a store succeeded the store faults 9f7e0ff
anonymous mmap with an unaligned offset mapped EINVAL a879ab9
mmap with MAP_HUGETLB on a file other than an MFD_HUGETLB memfd mapped EINVAL 3c14a5f
mmap or mprotect with PROT_SEM alone readable PROT_NONE d8daa8c
memfd_create with MFD_NOEXEC_SEAL, and the mode of any memfd no seal; mode 0600 F_SEAL_EXEC, held against fchmod, fchmodat2 with AT_EMPTY_PATH and a chmod of /proc/self/fd/N; mode 0777 or 0666 a9f75f9
faccessat2 and utimensat with AT_EMPTY_PATH EINVAL, or the host's error act on the descriptor; a descriptor on a /proc file is judged by the mode its path reports 9b47ac6
faccessat2 with AT_EMPTY_PATH on a /proc file whose name is gone, such as /proc/self/fdinfo/N after N is closed ENOENT answered for the open file ceb4104
linkat with AT_EMPTY_PATH EINVAL links the file the descriptor names; EXDEV for a descriptor on a /proc file 41b1d15
linkat with an empty old or new path and no AT_EMPTY_PATH ENOTDIR when dirfd is not a directory ENOENT 41b1d15
readlinkat(fd, "") on an O_PATH|O_NOFOLLOW descriptor of a symlink ENOTDIR the link's target; ENOENT for anything that is not a link 31ef112
clone with CLONE_THREAD and no CLONE_SIGHAND, or CLONE_SIGHAND and no CLONE_VM started a thread on the given stack, or forked EINVAL 2e728fc
a pidfd not close-on-exec in the guest's view close-on-exec 8004e78
fork-style clone with CLONE_PARENT_SETTID or CLONE_PIDFD neither slot was written both are 5fe4e1b
CLONE_PIDFD with a slot that cannot be written, or no descriptor left returned the pid and the child ran EFAULT or EMFILE, and no child 5fe4e1b
clone3 with CLONE_PIDFD|CLONE_PARENT_SETTID and pidfd == parent_tid both stores went to one word EINVAL 5fe4e1b
CLONE_VM child with CLONE_PARENT_SETTID the child read the slot's old contents the child reads its tid b469621

Five commits that deserve a closer look

9f7e0ff, a fresh PROT_READ mapping. A non-fixed mmap extended its 2 MiB block read-write and then gave the requested range the same permission whatever prot said. The comment on mprotect_same_prot_fast_path_safe records that this was known. The range now gets the permission asked for. This touches every non-fixed mmap, so I measured a map, touch-every-page, unmap loop three times before and three times after, in ns per iteration:

before after
anonymous 64 KiB 8250 to 9429 7674 to 8431
anonymous 8 MiB 91930 to 102307 86773 to 98790
file 64 KiB 10995 to 12451 12054 to 13738
file 8 MiB 561840 to 623260 545910 to 646010

Only the 64 KiB file case moves, by about 1 microsecond, and the runs overlap. If the read-write block was kept for a reason I did not see, this commit reverts on its own. It may also conflict with the open mmap pull requests.

c6919ce, O_TMPFILE. It stays unsupported, now by decision. macOS cannot link a file that has no name, so a file opened this way could never be published with linkat. open(2) documents EISDIR for a kernel without O_TMPFILE, callers fall back to a named temporary file on it, and that is what elfuse already answered by accident. An open that succeeded would turn that fallback into a failure with no answer. The row is marked unsupported:err:EISDIR.

9b47ac6, faccessat2 on a descriptor. macOS has no faccessat that takes a descriptor. The call asks fgetattrlist for ATTR_CMN_USERACCESS, which is the host's own answer for an open file, mode bits and ACL together, and needs no name. The answer is therefore the same for a file that was renamed or unlinked. A descriptor the host returns no such attribute for, such as a pipe, is judged by its mode bits against the credentials the host process runs with.

41b1d15, linkat on a descriptor. macOS has no linkat that takes a descriptor either, so the link goes through the name F_GETPATH returns. Used as a path, that name loses a race with rename: with one thread exchanging the file's directory through RENAME_EXCHANGE, 249 to 404 of 20000 calls linked a file the descriptor does not name, and a directory exchanged for a symlink leads out of the tree the guest is held in. The directory is opened once with O_NOFOLLOW_ANY and held, the entry in it is compared with the descriptor's file, and the host call acts on that directory and entry name. The same program links the wrong file 0 times in 20000. A file with no name left is ENOENT, which is also Linux's answer for a descriptor whose link count is zero.

5fe4e1b, sys_clone. sys_clone becomes a thin front and its old body becomes clone_dispatch. The INVENTORY entry in check-eintr-contract.py and the NOLINT marker for the function size follow the body. The pidfd is made inside clone_dispatch, at the point each path can still call the child off: after the fd table has gone to a forked child and before it is admitted, or before a thread or CLONE_VM child exists. In the second case the entry is found again by its generation, because the guest can close the fd number and get it back for another pidfd in between, and when the task then fails to start the descriptor is closed only if its fd generation still matches.

#354

#354 is assigned to @alanhc. Commit b99ceb4 changes stat_at_path in the way that issue sketches, because the same answer came out of the table for newfstatat, statx, fchownat and faccessat2. With this branch, the issue's table reads ENOENT in every row for fstatat(fd, "", &st, 0) and statx(fd, "", 0, ...): pipe, socketpair, eventfd, regular file, directory, AT_FDCWD and a closed descriptor, with --sysroot <empty dir> and without.

The issue's open question was whether a closed descriptor answers ENOENT or EBADF. On Linux 6.12.103 under qemu it answers ENOENT, and EBADF only with AT_EMPTY_PATH. elfuse now gives the same two answers.

The third case in the issue, a NULL pathname to statx with AT_EMPTY_PATH, is not touched here. If @alanhc would rather land their own change for the first two, b99ceb4 can come out of this branch.

Left as they are

  • Packet mode on a pipe. pipe2 O_DIRECT@packets is the one pending row: two writes then one read return both writes, where Linux returns the first. The host pipe is a byte stream. An AF_UNIX SOCK_DGRAM socketpair keeps the boundaries (SOCK_SEQPACKET is not supported on macOS), but end-of-file, EPIPE, a full pipe, poll after end-of-file and fstat all need translating, and F_SETFL(O_DIRECT) on a pipe that already exists cannot be honored at all. That is planned as a separate change. docs/testing.md has the note.
  • F_SETFL(O_DIRECT) on a pipe still records the bit without packet semantics, which tests/test-fcntl-flags.c asserts.
  • MFD_HUGETLB is accepted and backed by ordinary pages, and such a memfd takes MAP_HUGETLB. AT_EACCESS and O_NOCTTY have acceptance-only rows.
  • mprotect PROT_BTI is EINVAL, as Linux answers on a CPU without BTI: elfuse shows its guest ID_AA64PFR1_EL1 as 0. It has no row, because Linux's answer follows the qemu CPU, the host's under HVF (which shows BTI where the host has it) and cortex-a72 under TCG (which has none). PROT_MTE has a row: neither CPU has MTE.
  • readlinkat(fd, "") on an O_PATH descriptor of a /proc link, such as /proc/self/exe, is ENOENT: those descriptors carry no /proc stamp, so the link has nothing to be read from. The same call on a symlink whose last name is gone is ENOENT too, where Linux still reads it: macOS reads a link only by name.
  • A pidfd closed by number can race a reuse of that number before its cleanup hook runs, because pidfd_cleanup finds its entry by fd number. Every pidfd close has that window, not only the one clone makes on a failed start; closing it means passing the slot generation to the cleanup hooks of every fd type.
  • Not in the table yet: readlinkat (its empty-path cases are in test-file-ops), pidfd_open, openat2, mremap, msync, madvise, the splice family, the send/recv flags, epoll_ctl, clone3.

Checked by hand, with no test in the tree

  • aad176d: unlink on five directories set up on the host, because a guest cannot make an immutable directory or one owned by another user. Plain directory EISDIR; directory with uchg EPERM; own directory in a sticky parent EISDIR; directory in a uchg parent EPERM; root's directory in /tmp EPERM.
  • a9f75f9: on a MFD_NOEXEC_SEAL memfd, fchmod to 0777 is EPERM and to 0600 succeeds, and adding F_SEAL_EXEC|F_SEAL_SEAL reads back 0x21. The same program gives the same output on Linux 6.12.103.
  • c3b78d9: only the read-only case of PROT_GROWSDOWN on the stack has a row. The executable-stack path glibc uses was not run.
  • 9b47ac6: on a 0600 file with an ACL that denies write, faccessat2 with AT_EMPTY_PATH answers EACCES for W_OK before and after the unlink.
  • 5fe4e1b: EFAULT for a pidfd slot at address 8 and at 0 through clone, clone3 and CLONE_VM|CLONE_VFORK, EMFILE with RLIMIT_NOFILE at the next free descriptor, and no child to reap after any of them. Same answers on Linux 6.12. Only the address 8 case through clone has a row.
  • 41b1d15: the two descriptor cases in test-linkat-symlink-fallback need a Case-sensitive HFS+ image, where they return EOPNOTSUPP without the fallback.

Validation

Rebased on main at 8612033. At 2c747be every CI check passes. At a8c562e, before the last three commits:

  • make check, make check-format, make lint: exit 0.
  • tests/test-matrix.sh elfuse-aarch64: 300 passed, 0 failed, 10 skipped.
  • tests/test-matrix.sh qemu-aarch64: 278 passed, 0 failed, 32 skipped. This is the run that holds all 280 recorded answers against Linux.
  • scripts/check-commit-log.sh over the 30 commits (git rev-list 8612033..HEAD | scripts/check-commit-log.sh): exit 0.

At each of the 30 commits: make elfuse builds, test-flag-matrix passes under elfuse, and check-flag-matrix, check-syscall-coverage, check-eintr-contract, check-lock-order, check-atomics, check-ascii, .ci/check-format.sh and .ci/check-commentflow.sh pass.

Not run at this head: make verify, make test-rosetta-all, tests/test-matrix.sh elfuse-x86_64, make check-tsan, make check-asan, make check-contracts. The series was rewritten from 54 commits to 26 after the last runs of the first three, with the same final tree apart from the rebase, and has since gained the review fixes and one commit.

This is larger than most pull requests here. If it is easier to review in pieces, the test and its rows (3db8916, 36c20bc, ec2de4d, 800215c, 81abd03) can go first and the fixes follow by area, each with its own rows.


Summary by cubic

Adds a table of syscall flags with the answer Linux gives when each is set and when clear, a test that asserts both, and fixes for the divergences the table found. A flag elfuse dropped or mistranslated used to fail nothing unless a test happened to depend on it.

The test

  • tests/flag-matrix.tbl has one row per syscall and flag; scripts/gen-flag-matrix.py joins the rows against dispatch.tbl and generates vectors that tests/test-flag-matrix.c runs twice — flag set and clear — against a fresh fixture directory. Both answers are asserted, so a row passes only when the flag is what changes the result; 26 rows record that Linux answers the same either way.
  • pending marks a divergence still to fix (reported as KNOWN, fails once fixed); unsupported:ANSWER marks a declined flag carrying a documented Linux answer for a kernel without the feature. 136 rows over 26 syscalls, all answers hold on Linux 6.12 in the qemu lane.
  • check-syscall-coverage.py fails when one of the 26 syscalls loses all of its rows or a row names a syscall dispatch.tbl no longer serves.

The fixes

  • epoll_create1, inotify_init1, memfd_create, and accept4 now refuse undefined flag bits with EINVAL. unlink of a directory answers EISDIR except where Linux also answers EPERM; statx refuses both sync flags; pipe2(O_DIRECT) reports O_DIRECT on the write end; O_TMPFILE stays unsupported, now by decision.
  • An empty path without AT_EMPTY_PATH now answers ENOENT instead of the host's ENOTDIR (addressing the first cases of Empty path without AT_EMPTY_PATH reports ENOTDIR, not ENOENT, under a sysroot #354); linkat, faccessat2, and utimensat act on the descriptor when AT_EMPTY_PATH is set, and such a call now holds its directory open with O_NOFOLLOW_ANY so a rename or symlink exchange cannot redirect it outside the guest's tree. linkat on a symlink's O_PATH descriptor falls back to recreating the symlink on hosts that refuse the hard link, with the test asserting the target exactly. readlinkat with an empty path reads the symlink an O_PATH descriptor is open on.
  • faccessat2 on a descriptor uses the host's fgetattrlist access check, ACLs included, so the answer no longer depends on whether the file still has a name; for a synthetic file that has left its tree, the host fd behind the descriptor answers instead of ENOENT.
  • mmap and mprotect follow Linux on mapping types, descriptor lookup order, alignment, and protection bits; a fresh PROT_READ mapping is actually read-only. Under MAP_SHARED_VALIDATE, bits at or above 31 and MAP_FIXED_NOREPLACE are EOPNOTSUPP, MAP_HUGETLB on a file mapping is EINVAL, and PROT_SEM alone is PROT_NONE.
  • clone refuses flag pairs Linux refuses, writes the CLONE_PARENT_SETTID slot, publishes CLONE_PIDFD; pidfds are close-on-exec; CLONE_PIDFD with an unwritable slot or no descriptor left is EFAULT or EMFILE with no child, the slot is probed before a child is spawned, and a CLONE_VM child reads its tid in the slot. clone3 refuses CLONE_PIDFD|CLONE_PARENT_SETTID naming the same word. A pidfd is tracked by an internal generation rather than a guest fd number, so descriptor reuse cannot point an old entry at a new task.
  • memfd_create honors MFD_NOEXEC_SEAL with F_SEAL_EXEC, which now holds against every chmod spelling, and gives a memfd the mode Linux reports.
  • The only measurable cost is about 1 µs on a 64 KiB file mapping; the other measured map loops are unchanged or faster.
  • Packet mode on an O_DIRECT pipe stays pending by decision — the host pipe is a byte stream — with a pointer to docs/testing.md. MFD_HUGETLB, PROT_MTE, AT_EACCESS, and O_NOCTTY have acceptance-only rows, and O_CLOEXEC on the host open is invisible to a guest. The PROT_BTI acceptance row was dropped because the qemu lane's answer depends on whether the host CPU has BTI; elfuse still refuses the bit, as Linux does on a CPU without it. test-fd-lifecycle now requires a MAP_HUGETLB map of an MFD_HUGETLB memfd to succeed or answer ENOMEM, never EINVAL.

Written for commit 2c747be. Summary will update on new commits.

View guided diff

@xalestar
xalestar marked this pull request as ready for review October 9, 2026 19:35
cubic-dev-ai[bot]

This comment was marked as resolved.

@xalestar
xalestar marked this pull request as draft October 9, 2026 19:41
@xalestar
xalestar marked this pull request as ready for review October 9, 2026 20:43
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

A flag elfuse drops on the way to the host fails nothing unless some
test happens to depend on it, and the tests that name a flag mostly
check that the call returned.

tests/flag-matrix.tbl records, per syscall and flag, the arguments to
call it with and what Linux answers with the flag set and with it
clear. scripts/gen-flag-matrix.py joins the table against dispatch.tbl
and emits build/flag-matrix-vectors.h, and tests/test-flag-matrix.c
runs each row both ways in a fixture directory of its own. The driver
has no code for any one syscall. The fixture makes its file with mknod
and classifies a name with readlink and a following stat, so a dropped
O_CREAT, AT_REMOVEDIR or AT_SYMLINK_NOFOLLOW fails the rows of the
syscalls that use it and not the fixture.

A row can carry one of two marks for elfuse. pending is a divergence
still to be fixed: the mismatch is reported and not failed, and the
test fails once the row passes until the mark is removed. unsupported
gives the answer elfuse has for a flag it declines on purpose. The
qemu lane runs strict, where neither mark counts.

The first rows cover openat, unlinkat, newfstatat, statx, linkat,
fchmodat, fchownat, utimensat, faccessat2 and renameat2, and record
only answers elfuse already gives.
Rows for dup3, pipe2, eventfd2, epoll_create1, inotify_init1,
timerfd_create, signalfd4, memfd_create, socket, socketpair and
accept4: the close-on-exec and nonblocking flags of each, asserted set
and clear, and an undefined bit where elfuse already refuses one.

They use two argument kinds the driver already carries: an int pair
the call fills, whose two fds both have to give the answer, and a
listening Unix socket with one connection queued so that accept4
returns at once.
epoll_create1, inotify_init1, memfd_create and accept4 read the flags
they understand and ignored every other bit, so a call Linux answers
EINVAL for created an fd here. A guest probing for a newer flag by
trying it took the success as support.

Each refuses what Linux refuses, before doing anything else.
memfd_create also judges its flags before it reads the name, as Linux
does, and refuses MFD_EXEC beside MFD_NOEXEC_SEAL. The huge page size
bits stay acceptable only next to MFD_HUGETLB.

tests/flag-matrix.tbl gains a row for each refusal.
Linux refuses an empty pathname while it copies it in, before it
looks at dirfd, unless the call was given AT_EMPTY_PATH. elfuse passed
the empty name to the host, whose answer depends on dirfd: ENOENT
against a directory, ENOTDIR against anything else. A guest calling
fstatat on a file descriptor with an empty path and no AT_EMPTY_PATH
read ENOTDIR.

read_translated_path refuses the empty name itself, which covers
chdir, readlinkat, unlinkat, mkdirat, mknodat, symlinkat, faccessat,
truncate and utimensat. stat_at_path, sys_fchmodat and sys_fchownat
read their own path and act on AT_EMPTY_PATH first, so each has the
check after that branch. openat and renameat2 call path_translate_at
themselves and have it after their flag checks, the order Linux uses.

The check is not in path_translate_at, because execve, the shebang
interpreter, the abstract-socket bind and the FUSE mount target go
through it with their own answers for an empty name. linkat is left
for the change that gives it AT_EMPTY_PATH.
AT_STATX_FORCE_SYNC and AT_STATX_DONT_SYNC are two values of one
two-bit field, and Linux answers EINVAL for the value that sets both.
sys_statx listed the field as acceptable bits and so took the
contradictory request as valid.

tests/flag-matrix.tbl gains the row for it.
unlink, and unlinkat without AT_REMOVEDIR, on a directory is EISDIR
on Linux. macOS answers EPERM and elfuse passed that through, so rm
on a directory printed "Operation not permitted" where a Linux user
reads "Is a directory", and a caller that tries unlink first and
falls back to rmdir on EISDIR never fell back.

sys_unlinkat already stats the name before removing it. When the host
refuses with EPERM, AT_REMOVEDIR is not set and that stat saw a
directory, the answer is EISDIR. EPERM stays for the refusals Linux
decides in may_delete() before it looks at the entry's type: an
immutable or append-only victim or parent, and a sticky parent when
the caller owns neither the parent nor the victim. Those are judged
from the host's flags and credentials, and the parent is examined
only on the failure path.

The plain case has a row in tests/flag-matrix.tbl. The others need
host setup no guest can do and were checked by hand:

  plain directory                       EISDIR
  directory with the uchg flag          EPERM
  own directory in a sticky parent      EISDIR
  directory in a uchg parent            EPERM
  root's directory in sticky /tmp       EPERM
fcntl F_SETFL records O_DIRECT for a pipe and F_GETFL reads it back,
which tests/test-fcntl-flags.c pins. pipe2 accepted the same flag and
recorded nothing, so the one bit read differently depending on which
call set it.

pipe2 records it on the write end, where Linux keeps it; the read end
of such a pipe does not report it on Linux either.

This is about what F_GETFL says and nothing more. The host pipe has
no packet mode, so a guest that relies on one write arriving as one
read still does not get that, exactly as after F_SETFL.
openat with O_TMPFILE answered EISDIR by accident: nothing looked at
the bit, O_DIRECTORY with a write mode went to the host, and the host
refused to open a directory for writing. That happens to be what
open(2) documents for a kernel without O_TMPFILE, and callers fall
back to a named temporary file on it.

It stays the answer, by decision. macOS cannot link a file that has
no name, so a file opened this way could never be published with
linkat. An open that succeeded would turn the fallback those callers
have into a failure they have no answer for. The row for it in
tests/flag-matrix.tbl is marked unsupported with that answer.

What changes is the misuse. O_TMPFILE without a write mode is EINVAL
on Linux, and here it opened the directory itself and returned it.
That and __O_TMPFILE without O_DIRECTORY are refused while the flags
are checked.
fcntl has F_SETFL and F_SETFD asked of the descriptor that was passed
in, including the creation flags F_SETFL has to drop. mmap has
MAP_ANONYMOUS, MAP_SHARED on a file, MAP_FIXED and MAP_FIXED_NOREPLACE
against an address that is already taken, and the flags Linux accepts
without effect. mprotect has PROT_READ and PROT_WRITE.

The memory rows need answers the fd rows did not: whether a load or a
store at an address faults, caught with a SIGSEGV handler and
sigsetjmp, and whether a store through a mapping shows in a read of
the file. A fresh anonymous region per run gives the rows an address
that is occupied and pages whose protection they may change. The
handler jumps only while a probe is armed; any other fault gets the
default action and ends the run.
Rows for two flags that had no recorded answer. MFD_ALLOW_SEALING is
read back through F_GET_SEALS, and MFD_HUGETLB is an acceptance row.
pipe2 with O_DIRECT is asked for packet mode itself: two writes
followed by one read.

The pipe row is pending by decision. An O_DIRECT pipe here is a byte
stream, and the two ways out pull against each other: a framed
backing fixes pipe2 and cannot fix F_SETFL on a pipe that already
exists, and refusing the flag undoes an acceptance test-negative and
test-fcntl-flags assert. docs/testing.md names both so the row is not
mistaken for backlog.
Linux requires MAP_SHARED, MAP_PRIVATE or, for a file, the two
together as MAP_SHARED_VALIDATE, and answers EINVAL for anything
else. sys_mmap only asked whether MAP_SHARED was set, so a call with
neither flag made a private mapping and an anonymous
MAP_SHARED_VALIDATE made a shared one.

MAP_SHARED_VALIDATE exists so that a flag the file cannot honor is
refused and not ignored. Under that type any bit outside Linux's
LEGACY_MAP_MASK is EOPNOTSUPP, MAP_SYNC and MAP_FIXED_NOREPLACE
included. Linux takes the flags as a long, so the mmap wrapper folds
any bit above 31 into bit 31, which the mask refuses, where it used
to cut the argument to an int. Plain MAP_SHARED and MAP_PRIVATE keep
ignoring those bits, as Linux does.

The descriptor of a file mapping is validated ahead of the length and
type checks, because Linux resolves it first: a bad descriptor is
EBADF whatever else is wrong with the call.

Rows in tests/flag-matrix.tbl hold each refusal and the five huge
page size bits that stay accepted.
sys_mprotect read the three protection bits and ignored the rest. An
undefined bit succeeded, PROT_GROWSDOWN succeeded on any mapping, and
both grow bits were stored in the region's recorded protection, where
nothing expects them.

The grow bits come off first, as on Linux, and the request is judged:
both at once is EINVAL, PROT_GROWSUP is EINVAL because no arm64
mapping grows up, and PROT_GROWSDOWN is EINVAL unless the range lies
in the main stack. An undefined protection bit is EINVAL, and so are
PROT_BTI and PROT_MTE: arm64 admits each only on a CPU with the
feature, and the guest is shown neither. PROT_SEM stays accepted. A
zero length still returns 0 before the protection is looked at, which
is the order Linux has.

The qemu lane's guest is shown BTI where the host has it and accepts
PROT_BTI, so that row is marked unsupported with the answer of a CPU
without BTI.

On the stack PROT_GROWSDOWN carries the change from the mapping's low
end up to the end of the range given; glibc relies on that to make
the whole stack executable from one page. The start moves down to the
first page above the stack guard, and a range that starts inside the
guard is refused.

The stack row in tests/flag-matrix.tbl needs a span of stack that
nothing is using. The driver touches 256 KiB beneath its frame once
per run and takes a 64 KiB aligned span from the middle, so the
signal frame of a probe that faults lands above anything the row made
read-only, and the driver restores the protection afterwards.
A non-fixed mmap extends its 2 MiB block read-write, because the
block is shared with whatever else lands in it, and then set the
requested range to the same read-write permission whatever protection
was asked for. A PROT_READ mapping, anonymous or a private file
mapping alike, took a store without a fault until something called
mprotect on it. Linux raises SIGSEGV, and /proc/self/maps reported
r--p for the range the whole time. MAP_FIXED never had the problem,
since that path applies the requested permission to the exact range.

The range gets the permission that was asked for. Measured with a
map, touch-every-page, unmap loop, three runs before and three after,
in ns per iteration:

  anonymous 64 KiB   8250-9429 before   7674-8431 after
  anonymous 8 MiB    91930-102307       86773-98790
  file 64 KiB        10995-12451        12054-13738
  file 8 MiB         561840-623260      545910-646010

Only the 64 KiB file case moves, by about 1 microsecond, and the runs
overlap. The same-prot fast path in sys_mprotect stays as
conservative as it was, and its comment states why it no longer
strictly has to be.
The arm64 mmap entry point answers EINVAL for an offset that is not a
multiple of the page size before it looks at the flags or the
descriptor, so the test applies to an anonymous mapping as much as to
a file. sys_mmap applied it to file mappings only and gave an
anonymous one whatever offset it came with.

tests/flag-matrix.tbl gains the row for it.
mmap with MAP_HUGETLB and a file descriptor made an ordinary mapping
and returned its address. Linux 6.12 answers EINVAL for that on a
regular file, with MAP_SHARED, MAP_PRIVATE and MAP_SHARED_VALIDATE
alike: ksys_mmap_pgoff takes the flag on a file only when the file is
a hugetlbfs one. The validation mask is not where this is decided,
since MAP_HUGETLB is in LEGACY_MAP_MASK.

The only hugetlbfs file elfuse makes is an MFD_HUGETLB memfd, backed
by ordinary pages like the mapping, so the flag is EINVAL on every
other file mapping, after the descriptor lookup as on Linux. The memfd
carries a mark beside its seals, which dup and fork already carry and
F_GET_SEALS leaves out. An anonymous MAP_HUGETLB mapping is not
touched by this.

Two rows in tests/flag-matrix.tbl hold the refusal under MAP_SHARED
and MAP_SHARED_VALIDATE. test-fd-lifecycle maps an MFD_HUGETLB memfd
with MAP_HUGETLB, where Linux without a huge page pool may answer
ENOMEM but never EINVAL.
mmap and mprotect compare the protection with PROT_NONE to decide
whether a range is accessible, and build page permissions that always
include read. PROT_SEM passed that comparison as something other than
PROT_NONE, so a mapping asked for with PROT_SEM alone was readable.
Linux 6.12 faults on a load from it, after mmap and after mprotect:
PROT_SEM grants nothing, and calc_vm_prot_bits keeps only the read,
write and execute bits.

Both calls reduce the protection to those three bits once the request
is validated, so every later test and the region tracker see
PROT_NONE. That covers BTI and MTE, which were accepted and not acted
on already, and a bit mmap does not define, which Linux ignores there.

Three rows in tests/flag-matrix.tbl hold PROT_SEM alone under mmap
and mprotect and beside PROT_READ.
memfd_create accepted MFD_NOEXEC_SEAL and MFD_EXEC and did nothing
with either, and every memfd reported the 0600 of the temporary file
behind it.

A memfd has the mode Linux gives it: every permission bit, or no
execute bits under MFD_NOEXEC_SEAL. That flag also implies
MFD_ALLOW_SEALING and starts the file with F_SEAL_EXEC, where it used
to leave F_SEAL_SEAL set as if sealing had not been asked for.
MFD_HUGETLB stays accepted and backed by ordinary pages.

F_SEAL_EXEC is a seal F_ADD_SEALS takes. A chmod of a file that
carries it may not change the execute bits and answers EPERM, while
any other change to the mode goes through. The seal sits on the
inode, so fchmod, fchmodat2 with AT_EMPTY_PATH and a chmod of
/proc/self/fd/N all meet it, through one helper that holds the test
and the host fchmod. The magic-link spelling needs the seals of the
descriptor it resolved, so path_fd_magiclink_open_entry hands back
the table entry read under the same fd_lock hold as the reference. A
failed fstat in that helper returns its own errno, not EPERM.

Checked against Linux 6.12 with a one-off program: fchmod to 0777 is
EPERM and to 0600 succeeds on both, and adding F_SEAL_EXEC with
F_SEAL_SEAL reads back 0x21 on both. tests/flag-matrix.tbl gains the
mode and seal rows, with a sealed memfd as a fixture descriptor for
the fchmodat2 one.
Both calls take AT_EMPTY_PATH on Linux: with an empty path they act
on dirfd itself, the current directory included. sys_faccessat left
the flag out of its accepted set and answered EINVAL. sys_utimensat
accepted it and then handed the empty name to the host.

macOS has no access call that takes a descriptor, so faccessat2 asks
fgetattrlist for ATTR_CMN_USERACCESS, the host's own answer for an
open file, mode bits and ACL together. It needs no name, so the
answer is the same for a file that was renamed or unlinked. A
descriptor the host returns no such attribute for, such as a pipe, is
judged by its mode bits against the credentials the host process runs
with, since those are what the host answers by. F_OK needs neither:
an open descriptor is proof. The flags are judged before the path is
read, which is the order Linux uses, so an undefined bit is EINVAL
whatever the path. A descriptor on a synthetic file such as
/proc/self/status is judged by the mode the intercept reports, as its
path is, not by the staging file behind it.

utimensat applies the times with futimens, or to "." for AT_FDCWD. A
NULL path keeps its own rules: no flags, and EFAULT for AT_FDCWD.

tests/flag-matrix.tbl gains rows for F_OK and W_OK through a
descriptor, W_OK through an unlinked one, an undefined faccessat2
bit, and utimensat on a descriptor. A one-off program on a 0600 file
with an ACL that denies write reads EACCES for W_OK before and after
the unlink. test-file-ops checks that a descriptor on /proc/self/status
answers W_OK as its path does.
sys_linkat carried the fallback for a host that refuses to hard-link
a symlink inline, with four copies of the descriptor cleanup around
it. The fallback becomes linkat_symlink_fallback, which takes the two
directory descriptors and names, and sys_linkat closes its references
in one place.

No answer changes: the first four cases of
test-linkat-symlink-fallback pass on a Case-sensitive HFS+ image as
before. The helper is there for the AT_EMPTY_PATH form to call next.
linkat with AT_EMPTY_PATH and an empty old path gives the file
olddirfd names a second name. The flag was not in the accepted set,
so the request was EINVAL. Without the flag an empty old or new name
is ENOENT whatever its dirfd is, as for the other path calls; linkat
gave the host's ENOTDIR.

macOS has no call that links a descriptor, so the link goes through a
name and F_GETPATH is the only source of one. Used as a path, that
name loses a race with rename: with one thread exchanging the file's
directory through RENAME_EXCHANGE, 249 to 404 of 20000 calls linked a
file the descriptor does not name, and a directory exchanged for a
symlink leads out of the tree the guest is held in. So the directory
is opened once with O_NOFOLLOW_ANY and held, the entry in it is
compared with the descriptor's file, and the host call acts on that
directory and entry name without following the entry. The same
program links the wrong file 0 times in 20000 that way. What is left
is a rename over the entry inside that one directory after the
comparison.

A file with no name left is ENOENT, which is Linux's answer for a
descriptor whose link count is zero. A directory is EPERM. A
descriptor on a synthetic file is EXDEV, Linux's answer for a link out
of procfs, so the staging file behind it never gets a name in the
guest tree. An O_PATH
descriptor of a symlink goes through linkat_symlink_fallback when the
host filesystem refuses to hard-link a symlink, as the same link by
name does.

tests/flag-matrix.tbl gains the rows, and
test-linkat-symlink-fallback two cases for the descriptor form, which
return EOPNOTSUPP on a Case-sensitive HFS+ image without the
fallback.
readlinkat with an empty name reads the symlink its dirfd is open on,
which only an O_PATH|O_NOFOLLOW descriptor can be, and answers ENOENT
for anything else. It has no AT_EMPTY_PATH: the empty name alone asks
for it. elfuse answered ENOTDIR, and ENOENT since the empty-path change.

macOS reads a link only by name, so the link is read through the entry
that names the descriptor's file, held the way linkat finds it.
Rows for clone called as a fork: a zero stack and no CLONE_VM, so the
child gets a copy, has nothing to report and exits. The parent reads
the answer: the pid it was given, the child's exit status, and
whether the tid slot was left alone.

The slot of a clone row starts at -1, because 0 is a descriptor a
pidfd can be given. The driver compares getpid before and after every
call, so a row that makes a process when none was expected does not
run the rest of the table twice.
Linux answers EINVAL for CLONE_THREAD without CLONE_SIGHAND and for
CLONE_SIGHAND without CLONE_VM: a thread shares its signal handlers,
and shared handlers need shared memory. sys_clone dispatched on
CLONE_THREAD alone, so the first of those started a thread, and with
the zero stack of a fork-style call that thread faulted at once. The
second was treated as a plain fork.

Both are refused before anything is created. clone3 reaches the same
check through sys_clone. Every libc sets all three flags for a
thread.

tests/flag-matrix.tbl gains a row for each refusal.
Linux makes a pidfd close-on-exec whether it comes from pidfd_open or
from CLONE_PIDFD, and offers no flag to ask otherwise. pidfd_create
set the bit on its two host descriptors and never told the guest's fd
table, so F_GETFD read 0 and the descriptor survived a guest execve.

The row that shows this is the CLONE_PIDFD one in
tests/flag-matrix.tbl, which the next change makes reachable through
clone.
A clone without CLONE_VM goes down the fork path, and that path
returned the child's pid and nothing else. CLONE_PARENT_SETTID left
the parent's tid slot untouched, and CLONE_PIDFD was not understood
by clone at all: only clone3 stripped the flag and made the
descriptor, after the child existed and with any failure dropped.

The fork path stores the child's tid for CLONE_PARENT_SETTID once the
child exists, in the parent's memory only, as Linux does. sys_clone
becomes a front for clone_dispatch, which the EINTR inventory names in
its place. clone returns the pidfd through parent_tid, which is why
Linux refuses CLONE_PIDFD beside CLONE_PARENT_SETTID there, and the
same refusal is here.

Linux makes and stores the pidfd inside copy_process, so a full
descriptor table is EMFILE, an unwritable slot is EFAULT, and neither
leaves a child. clone_dispatch makes the pidfd where each path can
still back out. A fork makes it after the fd table has gone to the
child, which must not inherit the descriptor, and before the child is
admitted; the slot is also tried before the helper process is
spawned, so a bad pointer costs no spawn. A thread or a CLONE_VM
child shares this process's descriptor table, so its pidfd is made
and stored before the task exists and pointed at the tid afterwards.
The entry is found again by its generation, because the guest can
close the fd number and get it back for another pidfd in between.
When the thread or child then fails to start, the descriptor is
closed by the fd generation it was allocated with, for the same
reason. clone3 refuses the two flags only when pidfd and parent_tid
name the same word, as Linux does.

A one-off program gives the same answers on Linux 6.12 and here:
EFAULT for a slot at address 8 and at 0 through clone, clone3 and
CLONE_VM|CLONE_VFORK, EMFILE with RLIMIT_NOFILE at the next free
descriptor, and no child to reap after any of them.
tests/flag-matrix.tbl gains rows for both flags, the refused pair and
the address 8 case, and test-clone3 the shared slot.
With CLONE_VM a child runs on its parent's memory, so on Linux it
finds its own tid in the CLONE_PARENT_SETTID slot as soon as it runs:
on Linux 6.12 a CLONE_VM|CLONE_VFORK child reads its tid there. The
child here is a helper process on a snapshot of the parent, and the
parent stored the tid only after the child had been admitted and, for
CLONE_VFORK, had finished. The child read the slot's old contents.

For CLONE_VM the tid is stored before the snapshot is taken, so it is
in the child's copy. What the slot held is put back if the clone
fails, since Linux writes it only once the child exists. The store
after admission stays for a fork, whose child must not see it.

A slot that cannot be written is still passed over: Linux 6.12
returns the pid for CLONE_PARENT_SETTID with parent_tid at address 8.

tests/flag-matrix.tbl gains the answer child:ownptid, which has the
new process compare the slot with its tid, and a row that uses it.
The coverage audit counts a syscall as covered when a test mentions
its name, which says the call was made and nothing about its flags.
The rows in tests/flag-matrix.tbl say more, and nothing stopped them
from being deleted.

check-syscall-coverage.py lists the 26 syscalls that have rows and
fails when one of them loses all of its rows, when the list names a
syscall dispatch.tbl no longer serves, or when the table gains a
syscall the list does not name. It strips each line as the generator
does, so an indented row counts. These failures and the existing
coverage ones are reported together before the script exits.

It does not find a flag-taking syscall that never had rows. Nothing
in dispatch.tbl says which entries take flags, so that list grows by
hand, one batch of rows at a time.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 17 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/proc-pidfd.c">

<violation number="1" location="src/syscall/proc-pidfd.c:293">
P2: This cleanup can retire a newer pidfd that reused `gfd`: `fd_snapshot_and_close_gen` releases `fd_lock` before this callback runs, and `pidfd_cleanup` identifies entries by fd number only. Make pidfd cleanup identify the snapshot's generation instead of only its fd number.</violation>
</file>

<file name="src/syscall/fs.c">

<violation number="1" location="src/syscall/fs.c:3031">
P2: An `O_PATH` descriptor keeps an unlinked symlink readable on Linux, but this path returns `ENOENT` when `host_fd_live_entry` cannot find a name. Preserve the target for the descriptor or otherwise support reading the still-open symlink.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/syscall/proc-pidfd.c
*/
fd_entry_t snap;
if (gfd >= 0 && fd_snapshot_and_close_gen(gfd, fd_gen, &snap))
fd_cleanup_entry(gfd, &snap);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This cleanup can retire a newer pidfd that reused gfd: fd_snapshot_and_close_gen releases fd_lock before this callback runs, and pidfd_cleanup identifies entries by fd number only. Make pidfd cleanup identify the snapshot's generation instead of only its fd number.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/syscall/proc-pidfd.c, line 293:

<comment>This cleanup can retire a newer pidfd that reused `gfd`: `fd_snapshot_and_close_gen` releases `fd_lock` before this callback runs, and `pidfd_cleanup` identifies entries by fd number only. Make pidfd cleanup identify the snapshot's generation instead of only its fd number.</comment>

<file context>
@@ -267,6 +270,29 @@ void pidfd_set_target(uint64_t gen, int64_t target_pid)
+     */
+    fd_entry_t snap;
+    if (gfd >= 0 && fd_snapshot_and_close_gen(gfd, fd_gen, &snap))
+        fd_cleanup_entry(gfd, &snap);
+}
+
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The window is real, but it is not new here. Every close of a pidfd goes through fd_snapshot_and_close and then fd_cleanup_entry, and pidfd_cleanup finds its entry by fd number on that path too, so a reuse of the number between the two can retire the wrong entry today. Closing it needs the cleanup hook to receive the slot generation, which changes fd_register_cleanup for every fd type, so it belongs in a change of its own rather than in this one.

Comment thread tests/test-fd-lifecycle.c Outdated
Comment thread tests/flag-matrix.tbl Outdated
Comment thread src/syscall/fs.c
Comment thread src/syscall/fs.c
int64_t ref_err = host_fd_ref_open(dirfd, &ref);
if (ref_err < 0)
return ref_err;
int64_t rc = -LINUX_ENOENT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An O_PATH descriptor keeps an unlinked symlink readable on Linux, but this path returns ENOENT when host_fd_live_entry cannot find a name. Preserve the target for the descriptor or otherwise support reading the still-open symlink.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/syscall/fs.c, line 3031:

<comment>An `O_PATH` descriptor keeps an unlinked symlink readable on Linux, but this path returns `ENOENT` when `host_fd_live_entry` cannot find a name. Preserve the target for the descriptor or otherwise support reading the still-open symlink.</comment>

<file context>
@@ -2997,12 +2997,75 @@ int64_t sys_lseek(int fd, int64_t offset, int whence)
+    int64_t ref_err = host_fd_ref_open(dirfd, &ref);
+    if (ref_err < 0)
+        return ref_err;
+    int64_t rc = -LINUX_ENOENT;
+    struct stat st;
+    char host_path[LINUX_PATH_MAX];
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS has no call that reads a link through a descriptor: there is no freadlink, and a read of the O_SYMLINK descriptor elfuse holds returns EPERM. The target can only be read by name, so a link with no name left answers ENOENT, as the comment on the host branch of readlink_empty_path says. Keeping the target alive would mean reading and storing it when the O_PATH descriptor is opened, which is a change to openat rather than to this call.

faccessat2 with AT_EMPTY_PATH on a descriptor of a synthetic file asks
the intercept for the file's mode by the path the descriptor was
opened on. Such a file can leave its tree while the descriptor stays
open, /proc/self/fdinfo/N once N is closed, and the call then
answered ENOENT, where Linux answers for the open file.

When the intercept no longer knows the path, the host fd behind the
descriptor answers, as it did for every synthetic file before.
test-fd-lifecycle passed on any mmap failure but EINVAL for
MAP_HUGETLB on an MFD_HUGETLB memfd. Linux answers success, or
ENOMEM when no huge page pool is reserved, so any other error now
fails the test as well.
Linux admits PROT_BTI in mprotect only on a CPU with BTI. The qemu
lane runs the host CPU under HVF, which shows BTI on a host that has
it, and cortex-a72 under TCG, which has none, so the row's Linux
answer depends on where the lane runs. elfuse still refuses the bit,
as Linux on a CPU without BTI does. The PROT_MTE row stays: neither
lane CPU has MTE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant