Repository navigation
Conversation
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.
There was a problem hiding this comment.
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
| */ | ||
| fd_entry_t snap; | ||
| if (gfd >= 0 && fd_snapshot_and_close_gen(gfd, fd_gen, &snap)) | ||
| fd_cleanup_entry(gfd, &snap); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| int64_t ref_err = host_fd_ref_open(dirfd, &ref); | ||
| if (ref_err < 0) | ||
| return ref_err; | ||
| int64_t rc = -LINUX_ENOENT; |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
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.tblhas 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.pyjoins the rows againstdispatch.tbland writesbuild/flag-matrix-vectors.h, the same arrangementusbdev-ioctl-departeduses.tests/test-flag-matrix.cruns 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
strictand 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 asKNOWNand 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.ANSWERhas to be one Linux documents for a kernel or filesystem without the feature.One row is
pendingand one isunsupportedat the head; both are described under "Left as they are".Removing one translation at a time from
src/syscall/translate.cfails 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, theO_PATHbranch,O_SYNC/O_DSYNC,AT_REMOVEDIR,AT_SYMLINK_FOLLOW, andAT_SYMLINK_NOFOLLOWin both translators. RemovingO_CLOEXECfails nothing, because a guest cannot see whether the host open carried it: a guestexecvestays in the host process and the fork helper is spawned withPOSIX_SPAWN_CLOEXEC_DEFAULT. The table says so beside that row.check-syscall-coverage.pyfails 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
pendingto be unmarked later.epoll_create1,inotify_init1,memfd_create,accept4EINVALAT_EMPTY_PATH:newfstatat,statx,fchownat,fchmodat,openat,renameat2, and the calls that read a path throughread_translated_pathENOTDIRwhen dirfd is not a directoryENOENTstatxwith both sync flagsEINVALunlinkof a directoryEPERMEISDIR, except the three cases where Linux also saysEPERMpipe2(O_DIRECT)F_GETFLon the write end did not report itF_SETFLalready didopenatwithO_TMPFILE|O_RDONLYEINVALmmapwith neitherMAP_SHAREDnorMAP_PRIVATE, or anonymousMAP_SHARED_VALIDATEEINVALmmapunderMAP_SHARED_VALIDATEwithMAP_SYNC,MAP_FIXED_NOREPLACE, or any bit from 31 upEOPNOTSUPPmmapwith a bad descriptor and a bad typeEINVALEBADFmprotectwith an undefined bit,PROT_GROWSUP, orPROT_GROWSDOWNoff the stackEINVALmprotectwithPROT_BTIorPROT_MTEEINVAL, as on a CPU without the feature; the guest is shown neithermprotectwithPROT_GROWSDOWNon the stackPROT_READmappingmmapwith an unaligned offsetEINVALmmapwithMAP_HUGETLBon a file other than anMFD_HUGETLBmemfdEINVALmmapormprotectwithPROT_SEMalonePROT_NONEmemfd_createwithMFD_NOEXEC_SEAL, and the mode of any memfdF_SEAL_EXEC, held againstfchmod,fchmodat2withAT_EMPTY_PATHand a chmod of/proc/self/fd/N; mode 0777 or 0666faccessat2andutimensatwithAT_EMPTY_PATHEINVAL, or the host's error/procfile is judged by the mode its path reportsfaccessat2withAT_EMPTY_PATHon a/procfile whose name is gone, such as/proc/self/fdinfo/Nafter N is closedENOENTlinkatwithAT_EMPTY_PATHEINVALEXDEVfor a descriptor on a/procfilelinkatwith an empty old or new path and noAT_EMPTY_PATHENOTDIRwhen dirfd is not a directoryENOENTreadlinkat(fd, "")on anO_PATH|O_NOFOLLOWdescriptor of a symlinkENOTDIRENOENTfor anything that is not a linkclonewithCLONE_THREADand noCLONE_SIGHAND, orCLONE_SIGHANDand noCLONE_VMEINVALclonewithCLONE_PARENT_SETTIDorCLONE_PIDFDCLONE_PIDFDwith a slot that cannot be written, or no descriptor leftEFAULTorEMFILE, and no childclone3withCLONE_PIDFD|CLONE_PARENT_SETTIDandpidfd == parent_tidEINVALCLONE_VMchild withCLONE_PARENT_SETTIDFive commits that deserve a closer look
9f7e0ff, a fresh
PROT_READmapping. A non-fixedmmapextended its 2 MiB block read-write and then gave the requested range the same permission whateverprotsaid. The comment onmprotect_same_prot_fast_path_saferecords that this was known. The range now gets the permission asked for. This touches every non-fixedmmap, so I measured a map, touch-every-page, unmap loop three times before and three times after, in ns per iteration: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 withlinkat.open(2)documentsEISDIRfor a kernel withoutO_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 markedunsupported:err:EISDIR.9b47ac6,
faccessat2on a descriptor. macOS has nofaccessatthat takes a descriptor. The call asksfgetattrlistforATTR_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,
linkaton a descriptor. macOS has nolinkatthat takes a descriptor either, so the link goes through the nameF_GETPATHreturns. Used as a path, that name loses a race with rename: with one thread exchanging the file's directory throughRENAME_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 withO_NOFOLLOW_ANYand 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 isENOENT, which is also Linux's answer for a descriptor whose link count is zero.5fe4e1b,
sys_clone.sys_clonebecomes a thin front and its old body becomesclone_dispatch. TheINVENTORYentry incheck-eintr-contract.pyand theNOLINTmarker for the function size follow the body. The pidfd is made insideclone_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 orCLONE_VMchild 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_pathin the way that issue sketches, because the same answer came out of the table fornewfstatat,statx,fchownatandfaccessat2. With this branch, the issue's table readsENOENTin every row forfstatat(fd, "", &st, 0)andstatx(fd, "", 0, ...): pipe, socketpair, eventfd, regular file, directory,AT_FDCWDand a closed descriptor, with--sysroot <empty dir>and without.The issue's open question was whether a closed descriptor answers
ENOENTorEBADF. On Linux 6.12.103 under qemu it answersENOENT, andEBADFonly withAT_EMPTY_PATH. elfuse now gives the same two answers.The third case in the issue, a NULL pathname to
statxwithAT_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
pipe2 O_DIRECT@packetsis the onependingrow: two writes then one read return both writes, where Linux returns the first. The host pipe is a byte stream. An AF_UNIXSOCK_DGRAMsocketpair keeps the boundaries (SOCK_SEQPACKETis not supported on macOS), but end-of-file,EPIPE, a full pipe,pollafter end-of-file andfstatall need translating, andF_SETFL(O_DIRECT)on a pipe that already exists cannot be honored at all. That is planned as a separate change.docs/testing.mdhas the note.F_SETFL(O_DIRECT)on a pipe still records the bit without packet semantics, whichtests/test-fcntl-flags.casserts.MFD_HUGETLBis accepted and backed by ordinary pages, and such a memfd takesMAP_HUGETLB.AT_EACCESSandO_NOCTTYhave acceptance-only rows.mprotect PROT_BTIisEINVAL, as Linux answers on a CPU without BTI: elfuse shows its guestID_AA64PFR1_EL1as 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_MTEhas a row: neither CPU has MTE.readlinkat(fd, "")on anO_PATHdescriptor of a/proclink, such as/proc/self/exe, isENOENT: those descriptors carry no/procstamp, so the link has nothing to be read from. The same call on a symlink whose last name is gone isENOENTtoo, where Linux still reads it: macOS reads a link only by name.pidfd_cleanupfinds its entry by fd number. Every pidfd close has that window, not only the oneclonemakes on a failed start; closing it means passing the slot generation to the cleanup hooks of every fd type.readlinkat(its empty-path cases are intest-file-ops),pidfd_open,openat2,mremap,msync,madvise, the splice family, thesend/recvflags,epoll_ctl,clone3.Checked by hand, with no test in the tree
unlinkon five directories set up on the host, because a guest cannot make an immutable directory or one owned by another user. Plain directoryEISDIR; directory withuchgEPERM; own directory in a sticky parentEISDIR; directory in auchgparentEPERM; root's directory in/tmpEPERM.MFD_NOEXEC_SEALmemfd,fchmodto 0777 isEPERMand to 0600 succeeds, and addingF_SEAL_EXEC|F_SEAL_SEALreads back 0x21. The same program gives the same output on Linux 6.12.103.PROT_GROWSDOWNon the stack has a row. The executable-stack path glibc uses was not run.faccessat2withAT_EMPTY_PATHanswersEACCESforW_OKbefore and after the unlink.EFAULTfor a pidfd slot at address 8 and at 0 throughclone,clone3andCLONE_VM|CLONE_VFORK,EMFILEwithRLIMIT_NOFILEat the next free descriptor, and no child to reap after any of them. Same answers on Linux 6.12. Only the address 8 case throughclonehas a row.test-linkat-symlink-fallbackneed a Case-sensitive HFS+ image, where they returnEOPNOTSUPPwithout the fallback.Validation
Rebased on
mainat 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.shover the 30 commits (git rev-list 8612033..HEAD | scripts/check-commit-log.sh): exit 0.At each of the 30 commits:
make elfusebuilds,test-flag-matrixpasses under elfuse, andcheck-flag-matrix,check-syscall-coverage,check-eintr-contract,check-lock-order,check-atomics,check-ascii,.ci/check-format.shand.ci/check-commentflow.shpass.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.tblhas one row per syscall and flag;scripts/gen-flag-matrix.pyjoins the rows againstdispatch.tbland generates vectors thattests/test-flag-matrix.cruns 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.pendingmarks a divergence still to fix (reported asKNOWN, fails once fixed);unsupported:ANSWERmarks 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.pyfails when one of the 26 syscalls loses all of its rows or a row names a syscalldispatch.tblno longer serves.The fixes
epoll_create1,inotify_init1,memfd_create, andaccept4now refuse undefined flag bits withEINVAL.unlinkof a directory answersEISDIRexcept where Linux also answersEPERM;statxrefuses both sync flags;pipe2(O_DIRECT)reportsO_DIRECTon the write end;O_TMPFILEstays unsupported, now by decision.AT_EMPTY_PATHnow answersENOENTinstead of the host'sENOTDIR(addressing the first cases of Empty path without AT_EMPTY_PATH reports ENOTDIR, not ENOENT, under a sysroot #354);linkat,faccessat2, andutimensatact on the descriptor whenAT_EMPTY_PATHis set, and such a call now holds its directory open withO_NOFOLLOW_ANYso a rename or symlink exchange cannot redirect it outside the guest's tree.linkaton a symlink'sO_PATHdescriptor falls back to recreating the symlink on hosts that refuse the hard link, with the test asserting the target exactly.readlinkatwith an empty path reads the symlink anO_PATHdescriptor is open on.faccessat2on a descriptor uses the host'sfgetattrlistaccess 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 ofENOENT.mmapandmprotectfollow Linux on mapping types, descriptor lookup order, alignment, and protection bits; a freshPROT_READmapping is actually read-only. UnderMAP_SHARED_VALIDATE, bits at or above 31 andMAP_FIXED_NOREPLACEareEOPNOTSUPP,MAP_HUGETLBon a file mapping isEINVAL, andPROT_SEMalone isPROT_NONE.clonerefuses flag pairs Linux refuses, writes theCLONE_PARENT_SETTIDslot, publishesCLONE_PIDFD; pidfds are close-on-exec;CLONE_PIDFDwith an unwritable slot or no descriptor left isEFAULTorEMFILEwith no child, the slot is probed before a child is spawned, and aCLONE_VMchild reads its tid in the slot.clone3refusesCLONE_PIDFD|CLONE_PARENT_SETTIDnaming 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_createhonorsMFD_NOEXEC_SEALwithF_SEAL_EXEC, which now holds against every chmod spelling, and gives a memfd the mode Linux reports.O_DIRECTpipe stays pending by decision — the host pipe is a byte stream — with a pointer todocs/testing.md.MFD_HUGETLB,PROT_MTE,AT_EACCESS, andO_NOCTTYhave acceptance-only rows, andO_CLOEXECon the host open is invisible to a guest. ThePROT_BTIacceptance 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-lifecyclenow requires aMAP_HUGETLBmap of anMFD_HUGETLBmemfd to succeed or answerENOMEM, neverEINVAL.Written for commit 2c747be. Summary will update on new commits.