From 3db8916774d6ddd013fce80140387e1e660a5b77 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:11:20 +0800 Subject: [PATCH 01/34] Assert each syscall flag set and clear 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. --- Makefile | 16 ++ docs/testing.md | 18 ++ mk/tests.mk | 11 +- scripts/gen-flag-matrix.py | 267 ++++++++++++++++++++ tests/flag-matrix.tbl | 129 ++++++++++ tests/test-flag-matrix.c | 497 +++++++++++++++++++++++++++++++++++++ tests/test-matrix.sh | 12 +- 7 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 scripts/gen-flag-matrix.py create mode 100644 tests/flag-matrix.tbl create mode 100644 tests/test-flag-matrix.c diff --git a/Makefile b/Makefile index 482b74e3..f8be6aa6 100644 --- a/Makefile +++ b/Makefile @@ -114,6 +114,11 @@ DISPATCH_HEADER := $(BUILD_DIR)/dispatch.h DEPARTED_MANIFEST := tests/usbdev-ioctl-departed.tbl DEPARTED_GENERATOR := scripts/gen-usbdev-ioctl-departed.py DEPARTED_HEADER := $(BUILD_DIR)/usbdev-ioctl-departed-vectors.h +# The per-flag rows tests/test-flag-matrix.c runs. Generated under build/ for +# the same reason the departed-device vectors are. +FLAG_MATRIX_MANIFEST := tests/flag-matrix.tbl +FLAG_MATRIX_GENERATOR := scripts/gen-flag-matrix.py +FLAG_MATRIX_HEADER := $(BUILD_DIR)/flag-matrix-vectors.h HVF_LDFLAGS := -framework Hypervisor -framework IOKit -framework CoreFoundation -arch arm64 # Generated headers under build/ that must exist before compiling sources that @@ -155,6 +160,11 @@ $(DISPATCH_HEADER): $(DISPATCH_MANIFEST) $(DISPATCH_GENERATOR) src/syscall/abi.h $(BUILD_DIR)/syscall/syscall.o: $(DISPATCH_HEADER) +$(FLAG_MATRIX_HEADER): $(FLAG_MATRIX_MANIFEST) $(FLAG_MATRIX_GENERATOR) \ + $(DISPATCH_MANIFEST) | $(BUILD_DIR) + @echo " GEN $@" + $(Q)python3 $(FLAG_MATRIX_GENERATOR) --output $@ + $(DEPARTED_HEADER): $(DEPARTED_MANIFEST) $(DEPARTED_GENERATOR) \ src/syscall/usbdev.c | $(BUILD_DIR) @echo " GEN $@" @@ -402,6 +412,12 @@ $(BUILD_DIR)/test-usbdev-ioctl-departed: tests/test-usbdev-ioctl-departed.c \ @echo " CROSS $<" $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -I$(BUILD_DIR) -o $@ $< +# test-flag-matrix includes its generated rows the same way. +$(BUILD_DIR)/test-flag-matrix: tests/test-flag-matrix.c \ + $(FLAG_MATRIX_HEADER) | $(BUILD_DIR) + @echo " CROSS $<" + $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -I$(BUILD_DIR) -o $@ $< + # test-usbdev-ioctl churns open/read/close on one usbdevfs node from four # threads, so a close and a sibling's open contend for the same fd number. $(BUILD_DIR)/test-usbdev-ioctl: tests/test-usbdev-ioctl.c | $(BUILD_DIR) diff --git a/docs/testing.md b/docs/testing.md index 16989e29..12b440fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -116,6 +116,9 @@ What they do: asks make for the target list, which is the point: a `VERIFY__SRC` block written below the `:=` that builds `VERIFY_TARGETS` parses fine and generates no rule + - `scripts/gen-flag-matrix.py --check` so the generated rows of + `test-flag-matrix` match `tests/flag-matrix.tbl`, and so a row naming a + syscall `dispatch.tbl` no longer serves fails the build - the two harness self-tests, `test-config` (that `tests/test-config.sh` keeps its CLI mode separate from its sourced mode) and `test-runner` (the shared shell runner's output matching and exit-status checks), so @@ -535,6 +538,21 @@ answers with its primary alone, because the backing half belongs to a stream that has gone. Both rows are load-bearing in pairs -- neither number alone separates the answers the site could give -- so both are printed. +`test-flag-matrix` records what Linux answers for one flag of one syscall, +with the flag set and with it clear, one row each in `tests/flag-matrix.tbl`. +Both answers are asserted. Where they differ, the row passes only when the flag +is what changes the result; a row whose last field is `=` records that Linux +answers the same either way, and passes only when it does. The qemu lane runs +the binary with `strict`, which is what keeps +the recorded answers true; a row it refuses is a wrong row, not a kernel +difference to skip. Two marks exist for elfuse. `pending` is a divergence still +to be fixed: its mismatch is reported and not failed, and the lane fails once +the row passes, until the mark is removed. `unsupported:ANSWER` is a flag +elfuse declines on purpose, and ANSWER has to be one Linux documents for a +kernel or filesystem without the feature. To add a syscall, add rows; the +driver has no code for any one call. A new kind of argument or answer goes +into `scripts/gen-flag-matrix.py` and `tests/test-flag-matrix.c` together. + `test-usbdev-ioctl-departed` is the same idea with the recording moved out of the lane and into data. Its rows are generated from `tests/usbdev-ioctl-departed.tbl`, one per usbdevfs ioctl, and a row that diff --git a/mk/tests.mk b/mk/tests.mk index 069972eb..d9f8a411 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -7,7 +7,7 @@ # src/elfuse-limits.h. ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-nofile) -.PHONY: test-hello test-all check check-syscall-coverage check-eintr-contract check-lock-order check-atomics check-ascii check-usbdev-departed check-svc-tails check-skill-refs check-proof-targets test-gdbstub test-coreutils test-busybox test-shim-futex-stats test-vcpu-watchdog \ +.PHONY: test-hello test-all check check-syscall-coverage check-eintr-contract check-lock-order check-atomics check-ascii check-usbdev-departed check-flag-matrix check-svc-tails check-skill-refs check-proof-targets test-gdbstub test-coreutils test-busybox test-shim-futex-stats test-vcpu-watchdog \ test-static-bins \ test-dynamic test-dynamic-coreutils test-glibc-dynamic \ test-glibc-coreutils test-perf \ @@ -309,7 +309,7 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) $(CHECK_HOST_UNIT_BINS) $(CHECK_SHARED_LANES) ## Run the unit test suite plus busybox applet validation -check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage check-eintr-contract check-lock-order check-atomics check-ascii check-svc-tails check-skill-refs check-proof-targets check-usbdev-departed check-usb-fixture-bin test-config test-runner \ +check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage check-eintr-contract check-lock-order check-atomics check-ascii check-svc-tails check-skill-refs check-proof-targets check-usbdev-departed check-flag-matrix check-usb-fixture-bin test-config test-runner \ $(CHECK_HOST_UNIT_BINS) @bash tests/driver.sh -e $(ELFUSE_BIN) -d $(TEST_DIR) -v $(CHECK_SHARED_LANES) @@ -1829,6 +1829,13 @@ check-usb-fixture-bin: check-usbdev-departed: $(DEPARTED_HEADER) @python3 $(DEPARTED_GENERATOR) --check --output $(DEPARTED_HEADER) +## Verify the generated flag-matrix rows still match tests/flag-matrix.tbl +# +# The generator also joins the table against dispatch.tbl, so a row for a +# syscall the dispatcher no longer serves fails here. +check-flag-matrix: $(FLAG_MATRIX_HEADER) + @python3 $(FLAG_MATRIX_GENERATOR) --check --output $(FLAG_MATRIX_HEADER) + ## Build the fixture-enabled binary the two loopback lanes run # # The loopback fixture is not in the default build (USB_LOOPBACK_FIXTURE in diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py new file mode 100644 index 00000000..c4755a81 --- /dev/null +++ b/scripts/gen-flag-matrix.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate build/flag-matrix-vectors.h from tests/flag-matrix.tbl. + +Each table row names one syscall, one flag, the arguments to call it with, and +what Linux answers with the flag set and with it clear. The generator joins the +syscall names against src/syscall/dispatch.tbl, so a row for a syscall the +dispatcher does not serve fails here instead of quietly testing nothing. + +A row that starts with the word "pending" records an answer elfuse does not +give yet. The test reports its mismatch as known instead of failing, and fails +once the row passes, so the mark cannot outlive the divergence. + +A row that starts with "unsupported:ANSWER" records a flag elfuse declines on +purpose. ANSWER is what elfuse gives with the flag set, and it has to be an +answer Linux itself documents for a kernel or filesystem without the feature. +The qemu lane still asserts the row's own answer. + +Flag names and integer arguments are emitted as C expressions and resolved by +the guest toolchain's headers. No flag value is recorded in this script. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DEFAULT_TABLE = ROOT / "tests" / "flag-matrix.tbl" +DEFAULT_DISPATCH = ROOT / "src" / "syscall" / "dispatch.tbl" +DEFAULT_OUTPUT = ROOT / "build" / "flag-matrix-vectors.h" + +ENTRY_RE = re.compile(r"^SYS_([A-Za-z0-9_]+)\s+sc_[A-Za-z0-9_]+\s+[01]$") +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") +TAG_RE = re.compile(r"^[a-z0-9-]+$") +MAX_ARGS = 6 + +PATHS = { + "file": "FM_P_FILE", + "dir": "FM_P_DIR", + "link": "FM_P_LINK", + "dangling": "FM_P_DANGLING", + "new": "FM_P_NEW", + "empty": "FM_P_EMPTY", +} +FDS = {"file": "FM_FD_FILE", "dir": "FM_FD_DIR", "listener": "FM_FD_LISTENER"} + +# Expectations that take no operand. +PLAIN_EXPECTS = { + "ok": "FM_X_OK", + "fd": "FM_X_FD", + "fd:cloexec": "FM_X_FD_CLOEXEC", + "fd:nocloexec": "FM_X_FD_NOCLOEXEC", + "fd:appends": "FM_X_FD_APPENDS", + "fd:overwrites": "FM_X_FD_OVERWRITES", + "fd:readebadf": "FM_X_FD_READ_EBADF", + "buf:reg": "FM_X_STAT_REG", + "buf:lnk": "FM_X_STAT_LNK", + "bufx:reg": "FM_X_STATX_REG", + "bufx:lnk": "FM_X_STATX_LNK", +} +# Expectations whose operand is a C expression. +EXPR_EXPECTS = { + "err": "FM_X_ERR", + "errkeeps": "FM_X_ERR_KEEPS", + "fd:getfl": "FM_X_FD_GETFL", + "fd:nogetfl": "FM_X_FD_NOGETFL", + "pair1:getfl": "FM_X_PAIR1_GETFL", + "pair1:nogetfl": "FM_X_PAIR1_NOGETFL", +} +# Expectations whose operand is a fixture path. +PATH_EXPECTS = { + "exists": "FM_X_EXISTS", + "absent": "FM_X_ABSENT", + "lreg": "FM_X_LSTAT_REG", + "llnk": "FM_X_LSTAT_LNK", +} +# Expectations of the form kind:path=value. +PATH_VALUE_EXPECTS = {"size": "FM_X_SIZE", "mode": "FM_X_MODE"} + + +class TableError(ValueError): + pass + + +def load_dispatch(path: pathlib.Path) -> set[str]: + names = set() + for line in path.read_text(encoding="utf-8").splitlines(): + match = ENTRY_RE.match(line.strip()) + if match: + names.add(match.group(1)) + if not names: + raise TableError(f"{path}: no dispatch entries found") + return names + + +def path_token(where: str, name: str) -> str: + if name not in PATHS: + raise TableError(f"{where}: unknown fixture path '{name}'") + return PATHS[name] + + +def parse_arg(where: str, token: str) -> tuple[str, bool]: + """Return the C initializer for one argument and whether it is the flags + slot.""" + if token == "CWD": + return "{FM_A_CWD, 0}", False + if token == "buf": + return "{FM_A_BUF, 0}", False + if token == "pair": + return "{FM_A_PAIR, 0}", False + if token.startswith("p:"): + return f"{{FM_A_PATH, {path_token(where, token[2:])}}}", False + if token.startswith("fd:"): + if token[3:] not in FDS: + raise TableError(f"{where}: unknown fixture fd '{token[3:]}'") + return f"{{FM_A_FD, {FDS[token[3:]]}}}", False + if token == "F": + return "{FM_A_FLAGS, 0}", True + if token.startswith("F|"): + return f"{{FM_A_FLAGS, (long) ({token[2:]})}}", True + return f"{{FM_A_INT, (long) ({token})}}", False + + +def parse_expect(where: str, text: str) -> str: + if text in PLAIN_EXPECTS: + return f"{{{PLAIN_EXPECTS[text]}, 0, 0}}" + kind, _, operand = text.rpartition(":") + if kind in EXPR_EXPECTS and operand: + return f"{{{EXPR_EXPECTS[kind]}, (long) ({operand}), 0}}" + if kind.endswith(":p"): + base = kind[:-2] + if base in PATH_EXPECTS: + return f"{{{PATH_EXPECTS[base]}, {path_token(where, operand)}, 0}}" + if base in PATH_VALUE_EXPECTS and "=" in operand: + name, _, value = operand.partition("=") + return (f"{{{PATH_VALUE_EXPECTS[base]}, {path_token(where, name)}, " + f"(long) ({value})}}") + raise TableError(f"{where}: unknown expectation '{text}'") + + +def parse_table(path: pathlib.Path, dispatch: set[str]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + where = f"{path}:{lineno}" + pending = line.startswith("pending ") + if pending: + line = line[len("pending "):] + elfuse_text = "" + if line.startswith("unsupported:"): + elfuse_text, _, line = line[len("unsupported:"):].partition(" ") + if pending: + raise TableError(f"{where}: a row is pending or unsupported, " + "not both") + fields = [f.strip() for f in line.split(" | ")] + if len(fields) != 5: + raise TableError(f"{where}: expected 5 ' | '-separated fields, " + f"got {len(fields)}") + sysname, flag, args_text, with_text, without_text = fields + if not NAME_RE.match(sysname): + raise TableError(f"{where}: malformed syscall name '{sysname}'") + if sysname not in dispatch: + raise TableError(f"{where}: {sysname} is not in dispatch.tbl") + + flag_expr, _, tag = flag.partition("@") + if "@" in flag and not TAG_RE.match(tag): + raise TableError(f"{where}: malformed tag '{tag}'") + if not flag_expr: + raise TableError(f"{where}: empty flag expression") + if (sysname, flag) in seen: + raise TableError(f"{where}: duplicate row {sysname} {flag}") + seen.add((sysname, flag)) + + tokens = args_text.split() + if not 1 <= len(tokens) <= MAX_ARGS: + raise TableError(f"{where}: need 1 to {MAX_ARGS} arguments") + args = [] + flag_slots = 0 + for token in tokens: + init, is_flags = parse_arg(where, token) + args.append(init) + flag_slots += is_flags + if flag_slots != 1: + raise TableError(f"{where}: need exactly one F argument, " + f"got {flag_slots}") + + with_x = parse_expect(where, with_text) + without_x = with_x if without_text == "=" else parse_expect( + where, without_text) + elfuse_x = parse_expect(where, elfuse_text) if elfuse_text else with_x + rows.append({"sys": sysname, "label": flag, "flag": flag_expr, + "elfuse": elfuse_x, + "unsupported": "1" if elfuse_text else "0", + "args": ", ".join(args), "nargs": str(len(args)), + "with": with_x, "without": without_x, + "pending": "1" if pending else "0"}) + if not rows: + raise TableError(f"{path}: no rows") + return rows + + +def render(rows: list[dict[str, str]]) -> str: + out = [ + "/* Generated by scripts/gen-flag-matrix.py from tests/flag-matrix.tbl.", + " * Do not edit.", + " */", + "", + "static const struct fm_row fm_rows[] = {", + ] + for row in rows: + out.append(f' {{"{row["sys"]}", "{row["label"]}", __NR_{row["sys"]}, ' + f'(long) ({row["flag"]}),') + out.append(f' {row["pending"]}, {row["nargs"]}, {{{row["args"]}}},') + out.append(f' {row["with"]}, {row["without"]},') + out.append(f' {row["unsupported"]}, {row["elfuse"]}}},') + out.append("};") + out.append("") + return "\n".join(out) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--table", type=pathlib.Path, default=DEFAULT_TABLE) + parser.add_argument("--dispatch", type=pathlib.Path, + default=DEFAULT_DISPATCH) + parser.add_argument("--output", type=pathlib.Path, default=DEFAULT_OUTPUT) + parser.add_argument("--check", action="store_true", + help="fail if the output file is missing or stale") + parser.add_argument("--list", action="store_true", + help="print the syscalls the table covers and exit") + args = parser.parse_args() + + try: + rows = parse_table(args.table, load_dispatch(args.dispatch)) + except (OSError, TableError) as exc: + print(f"gen-flag-matrix: {exc}", file=sys.stderr) + return 1 + + if args.list: + for name in sorted({row["sys"] for row in rows}): + print(name) + return 0 + + text = render(rows) + if args.check: + try: + current = args.output.read_text(encoding="utf-8") + except OSError: + current = None + if current != text: + print(f"gen-flag-matrix: {args.output} is missing or stale", + file=sys.stderr) + return 1 + return 0 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl new file mode 100644 index 00000000..e656bf35 --- /dev/null +++ b/tests/flag-matrix.tbl @@ -0,0 +1,129 @@ +# What Linux answers for each flag of each syscall, with the flag set and with +# it clear. +# +# Copyright 2026 elfuse contributors +# SPDX-License-Identifier: Apache-2.0 +# +# scripts/gen-flag-matrix.py turns this file into build/flag-matrix-vectors.h +# and tests/test-flag-matrix.c runs every row twice: once with the flag ORed +# into the F argument and once without it. Both answers are asserted. Where they +# differ, a row only passes when the flag is what changes the result; a row +# ending in "=" records that the answer is the same either way. The qemu lane +# runs the same binary on a real kernel, which is what keeps the answers below +# honest. +# +# Format, fields separated by " | ": +# syscall | flag[@tag] | arguments | with the flag | without the flag +# +# flag is a C expression. @tag only tells apart two rows for the same flag. +# +# Arguments, in call order: +# CWD AT_FDCWD +# p:NAME path of a fixture entry (see below) +# fd:file fd:dir a fresh O_RDWR fd on "file", an O_RDONLY fd on "dir" +# fd:listener a listening Unix socket with one connection queued +# pair an int[2] the call fills with two fds; an fd answer is +# then asked of both +# F F|EXPR the flags slot; EXPR is ORed in for both runs +# anything else a C integer expression +# +# Fixture entries, built in a fresh directory for every run: +# file regular file, 10 bytes, mode 0644 +# dir empty directory +# link symlink to "file" +# dangling symlink to a name that does not exist +# new a name that does not exist +# empty the empty string +# +# Answers: +# ok the call returns >= 0 +# err:ENAME the call returns -ENAME +# errkeeps:ENAME the same, and "file" still holds its 10 bytes +# fd the call returns an fd +# fd:cloexec fd:nocloexec F_GETFD has, or lacks, FD_CLOEXEC +# fd:getfl:EXPR fd:nogetfl:EXPR F_GETFL has every bit, or no bit, of EXPR +# pair1:getfl:EXPR pair1:nogetfl:EXPR the same, asked only of the second +# fd of a pair +# fd:appends fd:overwrites a 1-byte write at offset 0 grows the file, +# or leaves its size alone +# fd:readebadf read on the fd gives EBADF +# exists:p:NAME absent:p:NAME the name is, or is not, there +# lreg:p:NAME llnk:p:NAME the name itself is a regular file, or a +# symlink +# size:p:NAME=N mode:p:NAME=N stat reports that size, or permission bits +# buf:reg buf:lnk the struct stat in buf is a regular file, or a symlink +# bufx:reg bufx:lnk the same for a struct statx +# = (last field only) the same answer as with the flag +# +# A row that starts with "unsupported:ANSWER" is a flag elfuse declines on +# purpose. ANSWER is what elfuse gives with the flag set, and it must be an +# answer Linux documents for a kernel or filesystem without the feature, so +# that a guest already knows what to do with it. A strict run ignores it. +# +# A row that starts with "pending" is an answer elfuse does not give yet. Under +# elfuse its mismatch is reported as KNOWN and does not fail the run; once both +# runs of the row pass, the test fails until the word is removed. The qemu lane +# passes "strict", which makes a pending row an ordinary one. + +# openat(dirfd, path, flags, mode) +# This row reads the guest's own view of the flag. Whether elfuse also passes +# O_CLOEXEC to the host open cannot be seen from a guest: a guest execve stays +# in the same host process and closes descriptors from elfuse's table, and the +# fork helper is spawned with every host descriptor closed by default. +openat | O_CLOEXEC | CWD p:file F|O_RDONLY 0 | fd:cloexec | fd:nocloexec +openat | O_NONBLOCK | CWD p:file F|O_RDONLY 0 | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +openat | O_APPEND | CWD p:file F|O_WRONLY 0 | fd:appends | fd:overwrites +openat | O_APPEND@getfl | CWD p:file F|O_WRONLY 0 | fd:getfl:O_APPEND | fd:nogetfl:O_APPEND +openat | O_CREAT | CWD p:new F|O_WRONLY 0600 | exists:p:new | err:ENOENT +openat | O_EXCL | CWD p:file F|O_CREAT|O_WRONLY 0600 | err:EEXIST | fd +openat | O_TRUNC | CWD p:file F|O_WRONLY 0 | size:p:file=0 | size:p:file=10 +openat | O_DIRECTORY | CWD p:file F|O_RDONLY 0 | err:ENOTDIR | fd +# The refusal comes before the open takes effect: the file is not truncated. +openat | O_DIRECTORY@no-truncate | CWD p:file F|O_WRONLY|O_TRUNC 0 | errkeeps:ENOTDIR | size:p:file=0 +openat | O_NOFOLLOW | CWD p:link F|O_RDONLY 0 | err:ELOOP | fd +openat | O_PATH | CWD p:file F|O_RDONLY 0 | fd:readebadf | fd +# O_PATH with O_NOFOLLOW opens the symlink itself; without O_PATH that is ELOOP. +openat | O_PATH@symlink | CWD p:link F|O_NOFOLLOW|O_RDONLY 0 | fd | err:ELOOP +# O_NOCTTY matters only when the name is a terminal the caller could adopt. +# That cannot be arranged here, so this row asserts that the flag is accepted. +openat | O_NOCTTY | CWD p:file F|O_RDONLY 0 | fd | = +openat | O_SYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_SYNC | fd:nogetfl:O_SYNC +openat | O_DSYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_DSYNC | fd:nogetfl:O_DSYNC +openat | O_NOATIME | CWD p:file F|O_RDONLY 0 | fd:getfl:O_NOATIME | fd:nogetfl:O_NOATIME + +unlinkat | AT_REMOVEDIR@file | CWD p:file F | err:ENOTDIR | absent:p:file +unlinkat | 0x8000@unknown | CWD p:file F | err:EINVAL | absent:p:file + +# newfstatat(dirfd, path, statbuf, flags) +newfstatat | AT_SYMLINK_NOFOLLOW | CWD p:link buf F | buf:lnk | buf:reg +newfstatat | AT_NO_AUTOMOUNT | CWD p:file buf F | buf:reg | = + +# statx(dirfd, path, flags, mask, statxbuf): the flags are argument 2 here and +# argument 3 in newfstatat. +statx | AT_SYMLINK_NOFOLLOW | CWD p:link F STATX_BASIC_STATS buf | bufx:lnk | bufx:reg +statx | AT_STATX_FORCE_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = +statx | AT_STATX_DONT_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = + +# linkat(olddirfd, oldpath, newdirfd, newpath, flags) +linkat | AT_SYMLINK_FOLLOW | CWD p:link CWD p:new F | lreg:p:new | llnk:p:new + +# fchmodat(dirfd, path, mode): the kernel entry takes three arguments, so a +# flags word in the fourth is never read. +fchmodat | AT_SYMLINK_NOFOLLOW@ignored | CWD p:link 0600 F | mode:p:file=0600 | = + +# fchownat(dirfd, path, owner, group, flags) +fchownat | AT_SYMLINK_NOFOLLOW | CWD p:dangling -1 -1 F | ok | err:ENOENT + +# utimensat(dirfd, path, times, flags) +utimensat | AT_SYMLINK_NOFOLLOW | CWD p:dangling 0 F | ok | err:ENOENT + +# faccessat2(dirfd, path, mode, flags) +faccessat2 | AT_SYMLINK_NOFOLLOW | CWD p:dangling F_OK F | ok | err:ENOENT +# AT_EACCESS matters only when the real and effective ids differ. That cannot +# be arranged here either, so this row asserts that the flag is accepted. +faccessat2 | AT_EACCESS | CWD p:file F_OK F | ok | = + +# renameat2(olddirfd, oldpath, newdirfd, newpath, flags) +renameat2 | RENAME_NOREPLACE | CWD p:file CWD p:link F | err:EEXIST | lreg:p:link +renameat2 | RENAME_EXCHANGE | CWD p:file CWD p:link F | llnk:p:file | absent:p:file +renameat2 | RENAME_NOREPLACE|RENAME_EXCHANGE@both | CWD p:file CWD p:link F | err:EINVAL | lreg:p:link diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c new file mode 100644 index 00000000..e8d7f17c --- /dev/null +++ b/tests/test-flag-matrix.c @@ -0,0 +1,497 @@ +/* + * One row per syscall flag: what the call answers with the flag and without + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The rows come from tests/flag-matrix.tbl through scripts/gen-flag-matrix.py. + * This file only knows how to build an argument, issue a raw syscall, and read + * back one kind of answer; it has no code for any particular syscall. Each row + * runs twice against a freshly built fixture directory, once with the flag ORed + * into the flags argument and once without, and both answers are asserted. A + * flag that elfuse drops on the way to the host therefore fails its "with" run, + * and a flag it applies unasked fails its "without" run. + * + * Every call goes through raw_syscall6 so that libc cannot rewrite the flags or + * pick a different syscall. + * + * Syscalls exercised: the ones tests/flag-matrix.tbl names. The coverage audit + * reads that list from the generator, not from this comment. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "raw-syscall.h" +#include "test-harness.h" + +int passes = 0, fails = 0; +static int known; + +#define FM_MAX_ARGS 6 +#define FM_FILE_SIZE 10 + +enum fm_arg_kind { + FM_A_INT, + FM_A_CWD, + FM_A_PATH, + FM_A_FD, + FM_A_BUF, + FM_A_PAIR, + FM_A_FLAGS, +}; +enum fm_path { + FM_P_FILE, + FM_P_DIR, + FM_P_LINK, + FM_P_DANGLING, + FM_P_NEW, + FM_P_EMPTY +}; +enum fm_fd { FM_FD_FILE, FM_FD_DIR, FM_FD_LISTENER }; + +enum fm_expect_kind { + FM_X_OK, + FM_X_ERR, + FM_X_ERR_KEEPS, + FM_X_FD, + FM_X_FD_CLOEXEC, + FM_X_FD_NOCLOEXEC, + FM_X_FD_GETFL, + FM_X_FD_NOGETFL, + FM_X_PAIR1_GETFL, + FM_X_PAIR1_NOGETFL, + FM_X_FD_APPENDS, + FM_X_FD_OVERWRITES, + FM_X_FD_READ_EBADF, + FM_X_EXISTS, + FM_X_ABSENT, + FM_X_LSTAT_REG, + FM_X_LSTAT_LNK, + FM_X_SIZE, + FM_X_MODE, + FM_X_STAT_REG, + FM_X_STAT_LNK, + FM_X_STATX_REG, + FM_X_STATX_LNK, +}; + +struct fm_arg { + enum fm_arg_kind kind; + long val; +}; + +struct fm_expect { + enum fm_expect_kind kind; + long a, b; +}; + +struct fm_row { + const char *sys, *flag; + long nr, bits; + int pending, nargs; + struct fm_arg args[FM_MAX_ARGS]; + struct fm_expect with, without; + int unsupported; + struct fm_expect elfuse_with; +}; + +#include "flag-matrix-vectors.h" + +static const char *const fm_paths[] = { + [FM_P_FILE] = "file", [FM_P_DIR] = "dir", [FM_P_LINK] = "link", + [FM_P_DANGLING] = "dangling", [FM_P_NEW] = "new", [FM_P_EMPTY] = "", +}; + +static char fm_buf[4096]; +static int fm_pair[2]; +static int fm_client = -1; +static char fm_why[160]; + +/* Every run gets a directory of its own under the base, built from nothing. + * Nothing is removed until the end, and the regular file is made with mknod, so + * the fixture leans on neither O_CREAT nor AT_REMOVEDIR. A row for one of those + * can then fail by itself when elfuse mishandles the flag, where it used to + * take the fixture and every later row with it. + */ +static char fm_base[64]; +static int fm_runs; + +static int fixture_enter(void) +{ + char name[16]; + snprintf(name, sizeof(name), "r%d", fm_runs); + if (chdir(fm_base) < 0 || mkdir(name, 0755) < 0 || chdir(name) < 0) + return -1; + fm_runs++; + + if (mknod("file", S_IFREG | 0644, 0) < 0) + return -1; + int fd = open("file", O_WRONLY); + if (fd < 0) + return -1; + ssize_t n = write(fd, "0123456789", FM_FILE_SIZE); + close(fd); + if (n != FM_FILE_SIZE) + return -1; + if (mkdir("dir", 0755) < 0 || symlink("file", "link") < 0 || + symlink("nowhere", "dangling") < 0) + return -1; + return 0; +} + +/* Best effort: a run that broke something has already been reported. */ +static void fixture_remove_all(void) +{ + static const char *const names[] = {"file", "dir", "link", + "dangling", "new", "sock"}; + for (int run = 0; run < fm_runs; run++) { + char name[16]; + snprintf(name, sizeof(name), "r%d", run); + if (chdir(fm_base) < 0 || chdir(name) < 0) + continue; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + if (unlink(names[i]) < 0) + rmdir(names[i]); + } + if (chdir(fm_base) == 0) + rmdir(name); + } + if (chdir("/") == 0) + rmdir(fm_base); +} + +/* What a fixture name is now, found without lstat: lstat is newfstatat with + * AT_SYMLINK_NOFOLLOW, and an answer read through a flag the table tests would + * fail rows that have nothing to do with it. + */ +enum fm_entry { FM_E_ABSENT, FM_E_LINK, FM_E_REG, FM_E_OTHER }; + +static enum fm_entry entry_kind(const char *name) +{ + char target[64]; + struct stat st; + if (readlink(name, target, sizeof(target)) >= 0) + return FM_E_LINK; + if (errno != EINVAL) + return FM_E_ABSENT; + if (stat(name, &st) < 0) + return FM_E_OTHER; + return S_ISREG(st.st_mode) ? FM_E_REG : FM_E_OTHER; +} + +/* A listening Unix socket with one connection already queued, so that an accept + * on it returns at once. The connecting end is kept in fm_client until the run + * is over. + */ +static long open_listener(void) +{ + struct sockaddr_un addr = {.sun_family = AF_UNIX, .sun_path = "sock"}; + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) < 0 || + listen(fd, 1) < 0) + goto fail; + fm_client = socket(AF_UNIX, SOCK_STREAM, 0); + if (fm_client < 0 || + connect(fm_client, (struct sockaddr *) &addr, sizeof(addr)) < 0) + goto fail; + return fd; +fail: + close(fd); + return -1; +} + +static long open_fixture_fd(long which) +{ + if (which == FM_FD_DIR) + return open("dir", O_RDONLY | O_DIRECTORY); + if (which == FM_FD_LISTENER) + return open_listener(); + return open("file", O_RDWR); +} + +/* Read back one answer. rc is the raw syscall return. On a mismatch the reason + * is left in fm_why and 0 is returned. + */ +static int answer_matches(const struct fm_expect *x, long rc) +{ + struct stat st; + int fl; + +#define WHY(...) snprintf(fm_why, sizeof(fm_why), __VA_ARGS__) + + switch (x->kind) { + case FM_X_OK: + case FM_X_FD: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + return 1; + case FM_X_ERR: + if (rc != -x->a) + return WHY("rc=%ld, want %ld", rc, -x->a), 0; + return 1; + case FM_X_FD_CLOEXEC: + case FM_X_FD_NOCLOEXEC: + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + fl = fcntl((int) rc, F_GETFD); + if (fl < 0) + return WHY("F_GETFD failed, errno=%d", errno), 0; + if (!!(fl & FD_CLOEXEC) != (x->kind == FM_X_FD_CLOEXEC)) + return WHY("F_GETFD=%#x", fl), 0; + return 1; + case FM_X_FD_GETFL: + case FM_X_FD_NOGETFL: + case FM_X_PAIR1_GETFL: + case FM_X_PAIR1_NOGETFL: { + int want_set = x->kind == FM_X_FD_GETFL || x->kind == FM_X_PAIR1_GETFL; + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + fl = fcntl((int) rc, F_GETFL); + if (fl < 0) + return WHY("F_GETFL failed, errno=%d", errno), 0; + if (want_set ? (fl & x->a) != x->a : (fl & x->a) != 0) + return WHY("F_GETFL=%#x, bits %#lx", fl, x->a), 0; + return 1; + } + case FM_X_FD_APPENDS: + case FM_X_FD_OVERWRITES: + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + if (lseek((int) rc, 0, SEEK_SET) != 0 || write((int) rc, "Z", 1) != 1 || + fstat((int) rc, &st) < 0) + return WHY("probe write failed, errno=%d", errno), 0; + if (st.st_size != FM_FILE_SIZE + (x->kind == FM_X_FD_APPENDS ? 1 : 0)) + return WHY("size %ld after a write at offset 0", (long) st.st_size), + 0; + return 1; + case FM_X_FD_READ_EBADF: + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + if (read((int) rc, fm_buf, 1) != -1 || errno != EBADF) + return WHY("read did not give EBADF, errno=%d", errno), 0; + return 1; + case FM_X_EXISTS: + case FM_X_ABSENT: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + if ((entry_kind(fm_paths[x->a]) != FM_E_ABSENT) != + (x->kind == FM_X_EXISTS)) + return WHY("\"%s\" %s", fm_paths[x->a], + x->kind == FM_X_EXISTS ? "is missing" : "still exists"), + 0; + return 1; + case FM_X_LSTAT_REG: + case FM_X_LSTAT_LNK: { + enum fm_entry kind = entry_kind(fm_paths[x->a]); + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + if (kind != (x->kind == FM_X_LSTAT_REG ? FM_E_REG : FM_E_LINK)) + return WHY("\"%s\" is entry kind %d", fm_paths[x->a], (int) kind), + 0; + return 1; + } + case FM_X_ERR_KEEPS: + if (rc != -x->a) + return WHY("rc=%ld, want %ld", rc, -x->a), 0; + if (stat("file", &st) < 0 || st.st_size != FM_FILE_SIZE) + return WHY("the refused call changed \"file\""), 0; + return 1; + case FM_X_SIZE: + case FM_X_MODE: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + if (stat(fm_paths[x->a], &st) < 0) + return WHY("stat \"%s\" failed, errno=%d", fm_paths[x->a], errno), + 0; + if (x->kind == FM_X_SIZE ? st.st_size != x->b + : (long) (st.st_mode & 07777) != x->b) + return WHY("\"%s\" has size %ld mode %#o", fm_paths[x->a], + (long) st.st_size, (unsigned) st.st_mode), + 0; + return 1; + case FM_X_STAT_REG: + case FM_X_STAT_LNK: { + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + memcpy(&st, fm_buf, sizeof(st)); + if (x->kind == FM_X_STAT_REG ? !S_ISREG(st.st_mode) + : !S_ISLNK(st.st_mode)) + return WHY("st_mode=%#o", (unsigned) st.st_mode), 0; + return 1; + } + case FM_X_STATX_REG: + case FM_X_STATX_LNK: { + struct statx sx; + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + memcpy(&sx, fm_buf, sizeof(sx)); + if (x->kind == FM_X_STATX_REG ? !S_ISREG(sx.stx_mode) + : !S_ISLNK(sx.stx_mode)) + return WHY("stx_mode=%#o", (unsigned) sx.stx_mode), 0; + return 1; + } + } +#undef WHY + return 0; +} + +static int returns_fd(const struct fm_expect *x) +{ + return x->kind == FM_X_FD || + (x->kind >= FM_X_FD_CLOEXEC && x->kind <= FM_X_FD_READ_EBADF); +} + +/* Returns 1 when the answer matched. A mismatch on a pending row is reported as + * known and not counted, unless the run is strict. + */ +static int run_one(const struct fm_row *row, int with, int strict) +{ + const struct fm_expect *x = with ? &row->with : &row->without; + char label[96]; + + /* A flag elfuse declines on purpose has its own recorded answer, which only + * a strict run sets aside. + */ + if (with && row->unsupported && !strict) + x = &row->elfuse_with; + long a[FM_MAX_ARGS] = {0}; + long opened[FM_MAX_ARGS]; + int nopened = 0, matched = 0, pair = 0; + + snprintf(label, sizeof(label), "%s %s %s", row->sys, row->flag, + with ? "set" : "clear"); + TEST(label); + + /* A broken fixture says nothing about this row's flag, so it ends the run + * rather than failing every row after it. + */ + if (fixture_enter() < 0) { + printf("FIXTURE BROKEN (errno=%d)\n", errno); + printf("\ntest-flag-matrix: fixture could not be built - FAIL\n"); + exit(2); + } + memset(fm_buf, 0, sizeof(fm_buf)); + + for (int i = 0; i < row->nargs; i++) { + const struct fm_arg *arg = &row->args[i]; + switch (arg->kind) { + case FM_A_INT: + a[i] = arg->val; + break; + case FM_A_CWD: + a[i] = AT_FDCWD; + break; + case FM_A_PATH: + a[i] = (long) fm_paths[arg->val]; + break; + case FM_A_FD: + a[i] = open_fixture_fd(arg->val); + if (a[i] < 0) { + FAIL("fixture fd open failed"); + goto out; + } + opened[nopened++] = a[i]; + break; + case FM_A_BUF: + a[i] = (long) fm_buf; + break; + case FM_A_PAIR: + fm_pair[0] = fm_pair[1] = -1; + a[i] = (long) fm_pair; + pair = 1; + break; + case FM_A_FLAGS: + a[i] = arg->val | (with ? row->bits : 0); + break; + } + } + + long rc = raw_syscall6(row->nr, a[0], a[1], a[2], a[3], a[4], a[5]); + + /* A call that fills an fd pair answers through both of them, unless the + * answer names the second one. + */ + if (pair && rc == 0 && + (x->kind == FM_X_PAIR1_GETFL || x->kind == FM_X_PAIR1_NOGETFL)) + matched = answer_matches(x, fm_pair[1]); + else if (pair && rc == 0 && returns_fd(x)) + matched = + answer_matches(x, fm_pair[0]) && answer_matches(x, fm_pair[1]); + else + matched = answer_matches(x, rc); + if (matched) { + PASS(); + } else if (row->pending && !strict) { + printf("KNOWN: %s\n", fm_why); + known++; + } else { + errno = 0; + FAIL(fm_why); + } + if (pair && rc == 0) { + close(fm_pair[0]); + close(fm_pair[1]); + } else if (rc >= 0 && returns_fd(x)) { + close((int) rc); + } + +out: + while (nopened > 0) + close((int) opened[--nopened]); + if (fm_client >= 0) { + close(fm_client); + fm_client = -1; + } + return matched; +} + +int main(int argc, char **argv) +{ + int strict = argc > 1 && strcmp(argv[1], "strict") == 0; + + printf("test-flag-matrix: %zu rows\n", + sizeof(fm_rows) / sizeof(fm_rows[0])); + snprintf(fm_base, sizeof(fm_base), "/tmp/flag-matrix-XXXXXX"); + if (!mkdtemp(fm_base)) { + perror("fixture directory"); + return 1; + } + + for (size_t i = 0; i < sizeof(fm_rows) / sizeof(fm_rows[0]); i++) { + int ok = run_one(&fm_rows[i], 1, strict); + ok &= run_one(&fm_rows[i], 0, strict); + if (ok && fm_rows[i].pending && !strict) { + char label[96]; + snprintf(label, sizeof(label), "%s %s", fm_rows[i].sys, + fm_rows[i].flag); + TEST(label); + errno = 0; + FAIL("row passes; remove \"pending\" from tests/flag-matrix.tbl"); + } + } + + fixture_remove_all(); + + if (known) + printf("\n%d known mismatch(es) on pending rows\n", known); + SUMMARY("test-flag-matrix"); + return fails ? 1 : 0; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 0aa6894f..3409be4a 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -839,6 +839,14 @@ run_unit_tests() test_rc "$runner" "test-exec-handoff" 0 "$bindir/test-exec-handoff" test_rc "$runner" "test-pipe-steal" 0 "$bindir/test-pipe-steal" test_check "$runner" "test-fcntl-flags" "0 failed" "$bindir/test-fcntl-flags" + + # A real kernel has to give every recorded answer, so the qemu lane runs the + # rows strict; under elfuse a row marked pending in tests/flag-matrix.tbl is + # reported and not failed. + local flag_matrix_mode="" + [ "$runner" = "run_qemu" ] && flag_matrix_mode="strict" + test_check "$runner" "test-flag-matrix" "0 failed" \ + "$bindir/test-flag-matrix" $flag_matrix_mode test_rc "$runner" "test-mprotect-mt" 0 "$bindir/test-mprotect-mt" test_check "$runner" "test-dup-setfl-race" "0 failed" \ "$bindir/test-dup-setfl-race" @@ -1540,8 +1548,8 @@ run_suite() # respelling of a path to the answer its canonical spelling gets. No fixture, # not in either skip list. 255 and 230, observed here at 297 and 275. EXPECTED_BASELINES=( - "elfuse-aarch64|255|0" - "qemu-aarch64|230|0" + "elfuse-aarch64|256|0" + "qemu-aarch64|231|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0" From 36c20bc27a2b64983baf41afff30d3373bfdb326 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:11:34 +0800 Subject: [PATCH 02/34] Cover the fd-creating calls in the flag matrix 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. --- tests/flag-matrix.tbl | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index e656bf35..0837c83c 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -127,3 +127,50 @@ faccessat2 | AT_EACCESS | CWD p:file F_OK F | ok | = renameat2 | RENAME_NOREPLACE | CWD p:file CWD p:link F | err:EEXIST | lreg:p:link renameat2 | RENAME_EXCHANGE | CWD p:file CWD p:link F | llnk:p:file | absent:p:file renameat2 | RENAME_NOREPLACE|RENAME_EXCHANGE@both | CWD p:file CWD p:link F | err:EINVAL | lreg:p:link + +# dup3(oldfd, newfd, flags) +dup3 | O_CLOEXEC | fd:file 100 F | fd:cloexec | fd:nocloexec +dup3 | O_NONBLOCK@unknown | fd:file 100 F | err:EINVAL | fd + +# pipe2(pipefd, flags) +pipe2 | O_CLOEXEC | pair F | fd:cloexec | fd:nocloexec +pipe2 | O_NONBLOCK | pair F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +pipe2 | O_APPEND@unknown | pair F | err:EINVAL | fd + +# eventfd2(initval, flags) +eventfd2 | EFD_CLOEXEC | 0 F | fd:cloexec | fd:nocloexec +eventfd2 | EFD_NONBLOCK | 0 F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +eventfd2 | EFD_SEMAPHORE | 0 F | fd | = +eventfd2 | 0x4@unknown | 0 F | err:EINVAL | fd + +# epoll_create1(flags) +epoll_create1 | EPOLL_CLOEXEC | F | fd:cloexec | fd:nocloexec + +# inotify_init1(flags) +inotify_init1 | IN_CLOEXEC | F | fd:cloexec | fd:nocloexec +inotify_init1 | IN_NONBLOCK | F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK + +# timerfd_create(clockid, flags) +timerfd_create | TFD_CLOEXEC | CLOCK_MONOTONIC F | fd:cloexec | fd:nocloexec +timerfd_create | TFD_NONBLOCK | CLOCK_MONOTONIC F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +timerfd_create | 0x1@unknown | CLOCK_MONOTONIC F | err:EINVAL | fd + +# signalfd4(fd, mask, sizemask, flags): buf is an empty 8-byte mask. +signalfd4 | SFD_CLOEXEC | -1 buf 8 F | fd:cloexec | fd:nocloexec +signalfd4 | SFD_NONBLOCK | -1 buf 8 F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +signalfd4 | 0x1@unknown | -1 buf 8 F | err:EINVAL | fd + +# memfd_create(name, flags) +memfd_create | MFD_CLOEXEC | p:file F | fd:cloexec | fd:nocloexec + +# socket(domain, type, protocol): the flags share the type argument. +socket | SOCK_CLOEXEC | AF_UNIX F|SOCK_STREAM 0 | fd:cloexec | fd:nocloexec +socket | SOCK_NONBLOCK | AF_UNIX F|SOCK_STREAM 0 | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK + +# socketpair(domain, type, protocol, sv) +socketpair | SOCK_CLOEXEC | AF_UNIX F|SOCK_STREAM 0 pair | fd:cloexec | fd:nocloexec +socketpair | SOCK_NONBLOCK | AF_UNIX F|SOCK_STREAM 0 pair | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK + +# accept4(sockfd, addr, addrlen, flags) +accept4 | SOCK_CLOEXEC | fd:listener 0 0 F | fd:cloexec | fd:nocloexec +accept4 | SOCK_NONBLOCK | fd:listener 0 0 F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK From d77b3b552f3f305888089710f9e1a66c6ba638b0 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:12:52 +0800 Subject: [PATCH 03/34] Refuse undefined flag bits in four syscalls 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. --- src/syscall/inotify.c | 3 +++ src/syscall/linux-wire.h | 8 ++++++++ src/syscall/net.c | 3 +++ src/syscall/poll.c | 3 +++ src/syscall/syscall.c | 18 ++++++++++++++++-- tests/flag-matrix.tbl | 6 ++++++ 6 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/syscall/inotify.c b/src/syscall/inotify.c index 75d14eb8..3644bfca 100644 --- a/src/syscall/inotify.c +++ b/src/syscall/inotify.c @@ -596,6 +596,9 @@ static int collect_events(inotify_instance_t *inst) int64_t sys_inotify_init1(int flags) { + if (flags & ~(IN_CLOEXEC | IN_NONBLOCK)) + return -LINUX_EINVAL; + int kq = kqueue(); if (kq < 0) return linux_errno(); diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 7c7e1410..01587cbc 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -544,6 +544,14 @@ typedef struct { /* memfd_create flags (MFD_*). */ #define LINUX_MFD_CLOEXEC 0x0001U #define LINUX_MFD_ALLOW_SEALING 0x0002U +#define LINUX_MFD_HUGETLB 0x0004U +#define LINUX_MFD_NOEXEC_SEAL 0x0008U +#define LINUX_MFD_EXEC 0x0010U + +/* The huge page size rides in the top bits and means something only beside + * MFD_HUGETLB. + */ +#define LINUX_MFD_HUGE_MASK (0x3fU << 26) /* fcntl sealing commands */ #define LINUX_F_ADD_SEALS 1033 diff --git a/src/syscall/net.c b/src/syscall/net.c index fd91d4c1..83289e90 100644 --- a/src/syscall/net.c +++ b/src/syscall/net.c @@ -735,6 +735,9 @@ int64_t sys_accept4(guest_t *g, uint64_t addrlen_gva, int flags) { + if (flags & ~(LINUX_SOCK_NONBLOCK | LINUX_SOCK_CLOEXEC)) + return -LINUX_EINVAL; + int nonblock = (flags & LINUX_SOCK_NONBLOCK) != 0; int cloexec = (flags & LINUX_SOCK_CLOEXEC) != 0; return do_accept(g, fd, addr_gva, addrlen_gva, nonblock, cloexec); diff --git a/src/syscall/poll.c b/src/syscall/poll.c index 442396d3..dd4fcdf3 100644 --- a/src/syscall/poll.c +++ b/src/syscall/poll.c @@ -1860,6 +1860,9 @@ static inline void epoll_merge_event(linux_epoll_event_t *out, int64_t sys_epoll_create1(int flags) { + if (flags & ~LINUX_EPOLL_CLOEXEC) + return -LINUX_EINVAL; + int kq = kqueue(); if (kq < 0) return linux_errno(); diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 572d23a9..d90e0b46 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1863,11 +1863,25 @@ static int64_t sc_memfd_create(guest_t *g, (void) x4; (void) x5; (void) verbose; + const unsigned int flags = (unsigned int) x1; + + /* Linux judges the flags before it reads the name. Which of the defined + * flags are honored is a separate question; this only refuses what Linux + * refuses. + */ + unsigned int known = LINUX_MFD_CLOEXEC | LINUX_MFD_ALLOW_SEALING | + LINUX_MFD_HUGETLB | LINUX_MFD_NOEXEC_SEAL | + LINUX_MFD_EXEC; + if (flags & LINUX_MFD_HUGETLB) + known |= LINUX_MFD_HUGE_MASK; + if (flags & ~known) + return -LINUX_EINVAL; + if ((flags & LINUX_MFD_EXEC) && (flags & LINUX_MFD_NOEXEC_SEAL)) + return -LINUX_EINVAL; + if (!x0) return -LINUX_EFAULT; - const unsigned int flags = (unsigned int) x1; - char first = '\0'; if (guest_read_small(g, x0, &first, sizeof(first)) < 0) return -LINUX_EFAULT; diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 0837c83c..60673808 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -145,10 +145,12 @@ eventfd2 | 0x4@unknown | 0 F | err:EINVAL | fd # epoll_create1(flags) epoll_create1 | EPOLL_CLOEXEC | F | fd:cloexec | fd:nocloexec +epoll_create1 | 0x1@unknown | F | err:EINVAL | fd # inotify_init1(flags) inotify_init1 | IN_CLOEXEC | F | fd:cloexec | fd:nocloexec inotify_init1 | IN_NONBLOCK | F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +inotify_init1 | 0x1@unknown | F | err:EINVAL | fd # timerfd_create(clockid, flags) timerfd_create | TFD_CLOEXEC | CLOCK_MONOTONIC F | fd:cloexec | fd:nocloexec @@ -162,6 +164,9 @@ signalfd4 | 0x1@unknown | -1 buf 8 F | err:EINVAL | fd # memfd_create(name, flags) memfd_create | MFD_CLOEXEC | p:file F | fd:cloexec | fd:nocloexec +memfd_create | 0x8000@unknown | p:file F | err:EINVAL | fd +# MFD_NOEXEC_SEAL and MFD_EXEC contradict each other. +memfd_create | 0x10@exec-and-noexec | p:file F|0x8 | err:EINVAL | fd # socket(domain, type, protocol): the flags share the type argument. socket | SOCK_CLOEXEC | AF_UNIX F|SOCK_STREAM 0 | fd:cloexec | fd:nocloexec @@ -174,3 +179,4 @@ socketpair | SOCK_NONBLOCK | AF_UNIX F|SOCK_STREAM 0 pair | fd:getfl:O_NONBLOCK # accept4(sockfd, addr, addrlen, flags) accept4 | SOCK_CLOEXEC | fd:listener 0 0 F | fd:cloexec | fd:nocloexec accept4 | SOCK_NONBLOCK | fd:listener 0 0 F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +accept4 | 0x1@unknown | fd:listener 0 0 F | err:EINVAL | fd From b99ceb4e62a8d408114399b6e70c139d9deaa13f Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:19:22 +0800 Subject: [PATCH 04/34] Answer ENOENT for an empty path on any dirfd 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. --- src/syscall/fs-stat.c | 6 ++++++ src/syscall/fs.c | 33 +++++++++++++++++++++++++++++++++ tests/flag-matrix.tbl | 7 +++++++ 3 files changed, 46 insertions(+) diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index e57fca81..5ea1ca0e 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -314,6 +314,12 @@ static int64_t stat_at_path(guest_t *g, dirfd != LINUX_AT_FDCWD) return stat_empty_path_fd(dirfd, mac_st); + /* Without AT_EMPTY_PATH an empty name is ENOENT whatever dirfd is. The host + * would say ENOTDIR for a dirfd that is not a directory. + */ + if (pathp[0] == '\0' && !(flags & LINUX_AT_EMPTY_PATH)) + return -LINUX_ENOENT; + if (pathp[0] == '/' && fuse_path_matches_mount(pathp)) { int frc = fuse_stat_path(pathp, mac_st, flags); if (frc < 0) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 5255fdcd..e65b5bd9 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -743,6 +743,14 @@ static int64_t read_translated_path(guest_t *g, { if (guest_read_str(g, path_gva, path, LINUX_PATH_MAX) < 0) return -LINUX_EFAULT; + + /* Linux refuses an empty name while it copies it in, before it looks at + * dirfd. Left to the host, the answer depended on dirfd: ENOENT against a + * directory and ENOTDIR against anything else. A caller that takes + * AT_EMPTY_PATH has to act on it before coming here. + */ + if (path[0] == '\0') + return -LINUX_ENOENT; if (path_translate_at(dirfd, path, tx_flags, tx) < 0) return linux_errno(); return 0; @@ -853,6 +861,13 @@ int64_t sys_openat_path(guest_t *g, (LINUX_O_DIRECTORY | LINUX_O_CREAT)) return -LINUX_EINVAL; + /* open has no AT_EMPTY_PATH, so an empty name is ENOENT whatever dirfd is; + * see read_translated_path. It comes after the flag checks above because + * Linux builds the open flags before it copies the name in. + */ + if (pathp[0] == '\0') + return -LINUX_ENOENT; + path_translation_t tx; unsigned int tx_flags = (linux_flags & LINUX_O_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE; @@ -3114,6 +3129,12 @@ int64_t sys_renameat2(guest_t *g, return -LINUX_EINVAL; } + /* An empty name on either side is ENOENT whatever its dirfd is; see + * read_translated_path. + */ + if (oldpath[0] == '\0' || newpath[0] == '\0') + return -LINUX_ENOENT; + if (path_translate_at(olddirfd, oldpath, PATH_TR_NOFOLLOW, &old_tx) < 0 || path_translate_at(newdirfd, newpath, PATH_TR_CREATE | PATH_TR_NOFOLLOW, &new_tx) < 0) @@ -3758,6 +3779,12 @@ int64_t sys_fchmodat(guest_t *g, return 0; } + /* Without AT_EMPTY_PATH an empty name is ENOENT whatever dirfd is; see + * read_translated_path. + */ + if (path[0] == '\0') + return -LINUX_ENOENT; + /* An fd magic link names the descriptor's file, and Linux resolves it * inside the syscall. Act on the descriptor so nothing can redirect the * chmod between resolution and use; see path_fd_magiclink_open(). @@ -3937,6 +3964,12 @@ int64_t sys_fchownat(guest_t *g, return out; } + /* Without AT_EMPTY_PATH an empty name is ENOENT whatever dirfd is; see + * read_translated_path. + */ + if (path[0] == '\0') + return -LINUX_ENOENT; + /* Same reasoning as the fd magic link branch in sys_fchmodat: act on the * descriptor, not on a pathname resolved from it a moment earlier. */ diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 60673808..43ef14fc 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -90,17 +90,21 @@ openat | O_NOCTTY | CWD p:file F|O_RDONLY 0 | fd | = openat | O_SYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_SYNC | fd:nogetfl:O_SYNC openat | O_DSYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_DSYNC | fd:nogetfl:O_DSYNC openat | O_NOATIME | CWD p:file F|O_RDONLY 0 | fd:getfl:O_NOATIME | fd:nogetfl:O_NOATIME +# An empty name is ENOENT before dirfd is looked at. +openat | O_CLOEXEC@empty-path | fd:file p:empty F|O_RDONLY 0 | err:ENOENT | = unlinkat | AT_REMOVEDIR@file | CWD p:file F | err:ENOTDIR | absent:p:file unlinkat | 0x8000@unknown | CWD p:file F | err:EINVAL | absent:p:file # newfstatat(dirfd, path, statbuf, flags) newfstatat | AT_SYMLINK_NOFOLLOW | CWD p:link buf F | buf:lnk | buf:reg +newfstatat | AT_EMPTY_PATH | fd:file p:empty buf F | buf:reg | err:ENOENT newfstatat | AT_NO_AUTOMOUNT | CWD p:file buf F | buf:reg | = # statx(dirfd, path, flags, mask, statxbuf): the flags are argument 2 here and # argument 3 in newfstatat. statx | AT_SYMLINK_NOFOLLOW | CWD p:link F STATX_BASIC_STATS buf | bufx:lnk | bufx:reg +statx | AT_EMPTY_PATH | fd:file p:empty F STATX_BASIC_STATS buf | bufx:reg | err:ENOENT statx | AT_STATX_FORCE_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = statx | AT_STATX_DONT_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = @@ -113,6 +117,7 @@ fchmodat | AT_SYMLINK_NOFOLLOW@ignored | CWD p:link 0600 F | mode:p:file=0600 | # fchownat(dirfd, path, owner, group, flags) fchownat | AT_SYMLINK_NOFOLLOW | CWD p:dangling -1 -1 F | ok | err:ENOENT +fchownat | AT_EMPTY_PATH | fd:file p:empty -1 -1 F | ok | err:ENOENT # utimensat(dirfd, path, times, flags) utimensat | AT_SYMLINK_NOFOLLOW | CWD p:dangling 0 F | ok | err:ENOENT @@ -127,6 +132,8 @@ faccessat2 | AT_EACCESS | CWD p:file F_OK F | ok | = renameat2 | RENAME_NOREPLACE | CWD p:file CWD p:link F | err:EEXIST | lreg:p:link renameat2 | RENAME_EXCHANGE | CWD p:file CWD p:link F | llnk:p:file | absent:p:file renameat2 | RENAME_NOREPLACE|RENAME_EXCHANGE@both | CWD p:file CWD p:link F | err:EINVAL | lreg:p:link +renameat2 | RENAME_NOREPLACE@empty-old | fd:file p:empty CWD p:new F | err:ENOENT | = +renameat2 | RENAME_NOREPLACE@empty-new | CWD p:file fd:file p:empty F | err:ENOENT | = # dup3(oldfd, newfd, flags) dup3 | O_CLOEXEC | fd:file 100 F | fd:cloexec | fd:nocloexec From 382b9540608550941ebe8e4d11716fe7f675aa0b Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:25:21 +0800 Subject: [PATCH 05/34] Refuse statx with both sync flags set 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. --- src/syscall/fs-stat.c | 6 ++++++ tests/flag-matrix.tbl | 1 + 2 files changed, 7 insertions(+) diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index 5ea1ca0e..78a24f9d 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -917,6 +917,12 @@ int64_t sys_statx(guest_t *g, LINUX_AT_NO_AUTOMOUNT | LINUX_AT_STATX_SYNC_TYPE)) return -LINUX_EINVAL; + /* FORCE_SYNC and DONT_SYNC are two values of one field, and Linux refuses + * the value that sets both. + */ + if ((flags & LINUX_AT_STATX_SYNC_TYPE) == LINUX_AT_STATX_SYNC_TYPE) + return -LINUX_EINVAL; + /* See sys_fstat comment on the zero-init rationale. */ struct stat mac_st = {0}; int64_t rc = stat_at_path(g, dirfd, path_gva, flags, &mac_st); diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 43ef14fc..02c947f1 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -107,6 +107,7 @@ statx | AT_SYMLINK_NOFOLLOW | CWD p:link F STATX_BASIC_STATS buf | bufx:lnk | bu statx | AT_EMPTY_PATH | fd:file p:empty F STATX_BASIC_STATS buf | bufx:reg | err:ENOENT statx | AT_STATX_FORCE_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = statx | AT_STATX_DONT_SYNC | CWD p:file F STATX_BASIC_STATS buf | bufx:reg | = +statx | AT_STATX_SYNC_TYPE@both | CWD p:file F STATX_BASIC_STATS buf | err:EINVAL | bufx:reg # linkat(olddirfd, oldpath, newdirfd, newpath, flags) linkat | AT_SYMLINK_FOLLOW | CWD p:link CWD p:new F | lreg:p:new | llnk:p:new From aad176db8a3f0ef9a3e74d088a57af4aedbdd234 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:30:10 +0800 Subject: [PATCH 06/34] Answer EISDIR for unlink of a directory 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 --- src/syscall/fs.c | 59 ++++++++++++++++++++++++++++++++++++++-- src/syscall/linux-wire.h | 1 + tests/flag-matrix.tbl | 2 ++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index e65b5bd9..84be8466 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -3021,6 +3021,45 @@ int64_t sys_readlinkat(guest_t *g, return (int64_t) copy_len; } +/* Whether Linux refuses to remove this entry before it looks at what kind of + * entry it is. may_delete() answers EPERM for an immutable or append-only + * victim, for an immutable or append-only parent, and for a sticky parent when + * the caller owns neither it nor the victim; only after those does unlink of a + * directory become EISDIR. The host enforces all three against its own + * credentials, so those are the ones compared. + */ +static bool unlink_refused_before_type(host_fd_t dirfd, + const char *host_path, + const struct stat *victim) +{ + const uint32_t locked = UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND; + if (victim->st_flags & locked) + return true; + + char parent[LINUX_PATH_MAX]; + str_copy_trunc(parent, host_path, sizeof(parent)); + size_t len = strlen(parent); + while (len > 1 && parent[len - 1] == '/') + parent[--len] = '\0'; + char *slash = strrchr(parent, '/'); + if (!slash) + str_copy_trunc(parent, ".", sizeof(parent)); + else if (slash == parent) + parent[1] = '\0'; + else + *slash = '\0'; + + struct stat dir_st; + if (fstatat(dirfd, parent, &dir_st, 0) < 0) + return false; + if (dir_st.st_flags & locked) + return true; + + uid_t euid = geteuid(); + return (dir_st.st_mode & S_ISVTX) && euid != 0 && euid != victim->st_uid && + euid != dir_st.st_uid; +} + int64_t sys_unlinkat(guest_t *g, int dirfd, uint64_t path_gva, int flags) { char path[LINUX_PATH_MAX]; @@ -3050,15 +3089,29 @@ int64_t sys_unlinkat(guest_t *g, int dirfd, uint64_t path_gva, int flags) host_fd_t unlink_dirfd = path_translation_dirfd(&tx, &dir_ref); struct stat removed_st; + bool removed_known = fstatat(unlink_dirfd, tx.host_path, &removed_st, + AT_SYMLINK_NOFOLLOW) == 0; bool clear_removed_overlay = - fstatat(unlink_dirfd, tx.host_path, &removed_st, AT_SYMLINK_NOFOLLOW) == - 0 && + removed_known && (removed_st.st_nlink <= 1 || (flags & LINUX_AT_REMOVEDIR)); + bool removed_is_dir = removed_known && S_ISDIR(removed_st.st_mode); int host_flags = translate_at_flags(flags); if (unlinkat(unlink_dirfd, tx.host_path, host_flags) < 0) { + int64_t err = linux_errno(); + + /* unlink of a directory is EISDIR on Linux and EPERM on macOS. The + * rewrite is keyed on the host's EPERM so that every other refusal + * keeps its own errno, and it stands aside for the refusals Linux also + * reports as EPERM, which it decides before the entry's type. + */ + if (err == -LINUX_EPERM && !(flags & LINUX_AT_REMOVEDIR) && + removed_is_dir && + !unlink_refused_before_type(unlink_dirfd, tx.host_path, + &removed_st)) + err = -LINUX_EISDIR; host_fd_ref_close(&dir_ref); - return linux_errno(); + return err; } if (clear_removed_overlay) diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 01587cbc..84ccd09c 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -58,6 +58,7 @@ typedef struct { #define LINUX_EXDEV 18 #define LINUX_ENODEV 19 #define LINUX_ENOTDIR 20 +#define LINUX_EISDIR 21 #define LINUX_EINVAL 22 #define LINUX_EMFILE 24 #define LINUX_ENOTTY 25 diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 02c947f1..4659bedf 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -93,6 +93,8 @@ openat | O_NOATIME | CWD p:file F|O_RDONLY 0 | fd:getfl:O_NOATIME | fd:nogetfl:O # An empty name is ENOENT before dirfd is looked at. openat | O_CLOEXEC@empty-path | fd:file p:empty F|O_RDONLY 0 | err:ENOENT | = +# unlinkat(dirfd, path, flags) +unlinkat | AT_REMOVEDIR | CWD p:dir F | absent:p:dir | err:EISDIR unlinkat | AT_REMOVEDIR@file | CWD p:file F | err:ENOTDIR | absent:p:file unlinkat | 0x8000@unknown | CWD p:file F | err:EINVAL | absent:p:file From 23dca1e969276d87280ab61f7b0568598407b5bc Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:45:03 +0800 Subject: [PATCH 07/34] Report O_DIRECT on a pipe2 write end 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. --- src/syscall/fs.c | 9 ++++++++- tests/flag-matrix.tbl | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 84be8466..a731ff1c 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2925,7 +2925,14 @@ int64_t sys_pipe2(guest_t *g, uint64_t fds_gva, int linux_flags) */ int shadow = linux_flags & (LINUX_O_CLOEXEC | LINUX_O_NONBLOCK); fd_publish_linux_flags(guest_fds[0], shadow); - fd_publish_linux_flags(guest_fds[1], shadow); + + /* Linux keeps O_DIRECT on the write end only, since packet mode is decided + * by the writer. F_SETFL already records the bit for a pipe, so a pipe made + * with it reads back the same way. Neither path gives the pipe packet + * semantics: the host has none. + */ + fd_publish_linux_flags(guest_fds[1], + shadow | (linux_flags & LINUX_O_DIRECT)); /* fd_alloc owns O_NONBLOCK on a pipe, so the guest's request is recorded * above and the host fds are already nonblocking. If ownership was refused diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 4659bedf..4c547b8c 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -145,6 +145,8 @@ dup3 | O_NONBLOCK@unknown | fd:file 100 F | err:EINVAL | fd # pipe2(pipefd, flags) pipe2 | O_CLOEXEC | pair F | fd:cloexec | fd:nocloexec pipe2 | O_NONBLOCK | pair F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK +# Packet mode is a property of the write end; the read end never reports it. +pipe2 | O_DIRECT | pair F | pair1:getfl:O_DIRECT | pair1:nogetfl:O_DIRECT pipe2 | O_APPEND@unknown | pair F | err:EINVAL | fd # eventfd2(initval, flags) From c6919ce26d43cea91171fa4a0f2fdba11e4f6acd Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:50:08 +0800 Subject: [PATCH 08/34] Decline O_TMPFILE and refuse its misuse 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. --- src/syscall/fs.c | 19 +++++++++++++++++++ tests/flag-matrix.tbl | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index a731ff1c..98878c89 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -861,6 +861,25 @@ int64_t sys_openat_path(guest_t *g, (LINUX_O_DIRECTORY | LINUX_O_CREAT)) return -LINUX_EINVAL; + /* O_TMPFILE is not provided, and what happens to it is the answer of a + * kernel that predates it: the bit is not acted on, O_DIRECTORY with a + * write mode stays, and opening the directory for writing is EISDIR. + * open(2) documents that errno for exactly this, and callers fall back to a + * named temporary file on it. Providing the open alone would be worse than + * providing nothing: macOS cannot link a file that has no name, so the + * linkat that publishes the file would fail with no fallback left. + * + * The two misuses Linux refuses while building the flags are refused the + * same way. Without the second, O_TMPFILE|O_RDONLY opened the directory + * itself and handed it back. + */ + if (linux_flags & LINUX___O_TMPFILE) { + if (!(linux_flags & LINUX_O_DIRECTORY)) + return -LINUX_EINVAL; + if ((linux_flags & LINUX_O_ACCMODE) == LINUX_O_RDONLY) + return -LINUX_EINVAL; + } + /* open has no AT_EMPTY_PATH, so an empty name is ENOENT whatever dirfd is; * see read_translated_path. It comes after the flag checks above because * Linux builds the open flags before it copies the name in. diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 4c547b8c..49fa5bf3 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -90,6 +90,13 @@ openat | O_NOCTTY | CWD p:file F|O_RDONLY 0 | fd | = openat | O_SYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_SYNC | fd:nogetfl:O_SYNC openat | O_DSYNC | CWD p:file F|O_WRONLY 0 | fd:getfl:O_DSYNC | fd:nogetfl:O_DSYNC openat | O_NOATIME | CWD p:file F|O_RDONLY 0 | fd:getfl:O_NOATIME | fd:nogetfl:O_NOATIME +# macOS cannot give a name back to a file that has none, so a file made with +# O_TMPFILE could never be linked into place afterwards. Callers that link it +# fall back to a named temporary file when the open fails, and would have no +# fallback left if the open succeeded and the linkat then failed. open(2) +# documents EISDIR as the answer of a kernel without O_TMPFILE. +unsupported:err:EISDIR openat | O_TMPFILE | CWD p:dir F|O_RDWR 0600 | fd | err:EISDIR +openat | O_TMPFILE@rdonly | CWD p:dir F|O_RDONLY 0600 | err:EINVAL | fd # An empty name is ENOENT before dirfd is looked at. openat | O_CLOEXEC@empty-path | fd:file p:empty F|O_RDONLY 0 | err:ENOENT | = From ec2de4daf61a8d42cef7b33e7e73893532e1811d Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:58:08 +0800 Subject: [PATCH 09/34] Cover fcntl, mmap and mprotect in the flag matrix 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. --- scripts/gen-flag-matrix.py | 17 ++++ tests/flag-matrix.tbl | 38 ++++++++ tests/test-flag-matrix.c | 172 +++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index c4755a81..892b6e17 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -59,6 +59,19 @@ "buf:lnk": "FM_X_STAT_LNK", "bufx:reg": "FM_X_STATX_REG", "bufx:lnk": "FM_X_STATX_LNK", + "arg0:cloexec": "FM_X_ARG0_CLOEXEC", + "arg0:nocloexec": "FM_X_ARG0_NOCLOEXEC", + "map": "FM_X_MAP", + "map:atregion": "FM_X_MAP_AT_REGION", + "map:elsewhere": "FM_X_MAP_ELSEWHERE", + "map:writable": "FM_X_MAP_WRITABLE", + "map:readonly": "FM_X_MAP_READONLY", + "map:noread": "FM_X_MAP_NOREAD", + "map:writesfile": "FM_X_MAP_WRITES_FILE", + "map:keepsfile": "FM_X_MAP_KEEPS_FILE", + "region:writable": "FM_X_REGION_WRITABLE", + "region:readonly": "FM_X_REGION_READONLY", + "region:noread": "FM_X_REGION_NOREAD", } # Expectations whose operand is a C expression. EXPR_EXPECTS = { @@ -68,6 +81,8 @@ "fd:nogetfl": "FM_X_FD_NOGETFL", "pair1:getfl": "FM_X_PAIR1_GETFL", "pair1:nogetfl": "FM_X_PAIR1_NOGETFL", + "arg0:getfl": "FM_X_ARG0_GETFL", + "arg0:nogetfl": "FM_X_ARG0_NOGETFL", } # Expectations whose operand is a fixture path. PATH_EXPECTS = { @@ -110,6 +125,8 @@ def parse_arg(where: str, token: str) -> tuple[str, bool]: return "{FM_A_BUF, 0}", False if token == "pair": return "{FM_A_PAIR, 0}", False + if token == "region": + return "{FM_A_REGION, 0}", False if token.startswith("p:"): return f"{{FM_A_PATH, {path_token(where, token[2:])}}}", False if token.startswith("fd:"): diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 49fa5bf3..92f3952a 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -24,6 +24,7 @@ # fd:listener a listening Unix socket with one connection queued # pair an int[2] the call fills with two fds; an fd answer is # then asked of both +# region the address of a fresh anonymous read-write mapping # F F|EXPR the flags slot; EXPR is ORed in for both runs # anything else a C integer expression # @@ -53,6 +54,16 @@ # size:p:NAME=N mode:p:NAME=N stat reports that size, or permission bits # buf:reg buf:lnk the struct stat in buf is a regular file, or a symlink # bufx:reg bufx:lnk the same for a struct statx +# arg0:cloexec arg0:nocloexec arg0:getfl:EXPR arg0:nogetfl:EXPR +# the fd answers, asked of the fd passed as first argument +# map the call returns an address +# map:atregion map:elsewhere that address is, or is not, the region's +# map:writable map:readonly map:noread +# a load and a store at that address work or fault +# map:writesfile map:keepsfile a store there shows, or does not show, in +# a read of "file" +# region:writable region:readonly region:noread +# the same loads and stores, on the region # = (last field only) the same answer as with the flag # # A row that starts with "unsupported:ANSWER" is a flag elfuse declines on @@ -199,3 +210,30 @@ socketpair | SOCK_NONBLOCK | AF_UNIX F|SOCK_STREAM 0 pair | fd:getfl:O_NONBLOCK accept4 | SOCK_CLOEXEC | fd:listener 0 0 F | fd:cloexec | fd:nocloexec accept4 | SOCK_NONBLOCK | fd:listener 0 0 F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK accept4 | 0x1@unknown | fd:listener 0 0 F | err:EINVAL | fd + +# fcntl(fd, cmd, arg): the flags are the argument of F_SETFL and F_SETFD. +fcntl | O_NONBLOCK@setfl | fd:file F_SETFL F | arg0:getfl:O_NONBLOCK | arg0:nogetfl:O_NONBLOCK +fcntl | O_APPEND@setfl | fd:file F_SETFL F | arg0:getfl:O_APPEND | arg0:nogetfl:O_APPEND +fcntl | O_NOATIME@setfl | fd:file F_SETFL F | arg0:getfl:O_NOATIME | arg0:nogetfl:O_NOATIME +# A regular file has no fasync handler, so F_SETFL leaves O_ASYNC clear on it. +fcntl | O_ASYNC@setfl-file | fd:file F_SETFL F | arg0:nogetfl:O_ASYNC | = +fcntl | FD_CLOEXEC@setfd | fd:file F_SETFD F | arg0:cloexec | arg0:nocloexec +# F_SETFL changes only the status flags; a creation flag in it is dropped. +fcntl | O_TRUNC@setfl-ignored | fd:file F_SETFL F | size:p:file=10 | = +fcntl | O_SYNC@setfl-ignored | fd:file F_SETFL F | arg0:nogetfl:O_SYNC | = + +# mmap(addr, length, prot, flags, fd, offset): the flags are argument 3. +mmap | MAP_ANONYMOUS | 0 4096 PROT_READ F|MAP_PRIVATE -1 0 | map | err:EBADF +mmap | MAP_FIXED | region 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:atregion | map:elsewhere +mmap | MAP_FIXED_NOREPLACE | region 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | err:EEXIST | map:elsewhere +# MAP_SHARED beside MAP_PRIVATE is MAP_SHARED_VALIDATE, which is shared. +mmap | MAP_SHARED@file | 0 4096 PROT_READ|PROT_WRITE F|MAP_PRIVATE fd:file 0 | map:writesfile | map:keepsfile +mmap | 0x80000@sync-legacy | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | map | = +mmap | MAP_POPULATE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = +mmap | MAP_NORESERVE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = +mmap | MAP_STACK | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = + + +# mprotect(addr, length, prot) +mprotect | PROT_READ | region 4096 F | region:readonly | region:noread +mprotect | PROT_WRITE | region 4096 F|PROT_READ | region:writable | region:readonly diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index e8d7f17c..15ae7995 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -21,6 +21,8 @@ #include #include +#include +#include #include #include #include @@ -53,6 +55,7 @@ enum fm_arg_kind { FM_A_FD, FM_A_BUF, FM_A_PAIR, + FM_A_REGION, FM_A_FLAGS, }; enum fm_path { @@ -89,6 +92,21 @@ enum fm_expect_kind { FM_X_STAT_LNK, FM_X_STATX_REG, FM_X_STATX_LNK, + FM_X_ARG0_CLOEXEC, + FM_X_ARG0_NOCLOEXEC, + FM_X_ARG0_GETFL, + FM_X_ARG0_NOGETFL, + FM_X_MAP, + FM_X_MAP_AT_REGION, + FM_X_MAP_ELSEWHERE, + FM_X_MAP_WRITABLE, + FM_X_MAP_READONLY, + FM_X_MAP_NOREAD, + FM_X_MAP_WRITES_FILE, + FM_X_MAP_KEEPS_FILE, + FM_X_REGION_WRITABLE, + FM_X_REGION_READONLY, + FM_X_REGION_NOREAD, }; struct fm_arg { @@ -120,6 +138,57 @@ static const char *const fm_paths[] = { static char fm_buf[4096]; static int fm_pair[2]; +static long fm_arg0; + +/* A fresh anonymous read-write mapping for the rows that need an address that + * is already taken, or pages whose protection they change. + */ +#define FM_REGION_SIZE (2 * 65536) +static char *fm_region; + +static sigjmp_buf fm_fault_jmp; +static volatile sig_atomic_t fm_probe_armed; + +/* fm_fault_jmp is only a place to jump to while can_read or can_write is + * running. A fault from anywhere else gets the default action, so it ends the + * run with the signal rather than resuming in a frame that has returned. + */ +static void on_fault(int sig) +{ + if (!fm_probe_armed) { + signal(sig, SIG_DFL); + return; + } + fm_probe_armed = 0; + siglongjmp(fm_fault_jmp, 1); +} + +static int can_read(const volatile char *p) +{ + if (sigsetjmp(fm_fault_jmp, 1)) + return 0; + fm_probe_armed = 1; + volatile char c = *p; + (void) c; + fm_probe_armed = 0; + return 1; +} + +static int can_write(volatile char *p) +{ + if (sigsetjmp(fm_fault_jmp, 1)) + return 0; + fm_probe_armed = 1; + *p = 'Z'; + fm_probe_armed = 0; + return 1; +} + +/* A raw mmap return is an address unless it is one of the top 4095 values. */ +static int is_mapping(long rc) +{ + return (unsigned long) rc < (unsigned long) -4095L; +} static int fm_client = -1; static char fm_why[160]; @@ -337,6 +406,90 @@ static int answer_matches(const struct fm_expect *x, long rc) return WHY("st_mode=%#o", (unsigned) st.st_mode), 0; return 1; } + case FM_X_ARG0_CLOEXEC: + case FM_X_ARG0_NOCLOEXEC: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + fl = fcntl((int) fm_arg0, F_GETFD); + if (fl < 0 || !!(fl & FD_CLOEXEC) != (x->kind == FM_X_ARG0_CLOEXEC)) + return WHY("F_GETFD=%#x", fl), 0; + return 1; + case FM_X_ARG0_GETFL: + case FM_X_ARG0_NOGETFL: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + fl = fcntl((int) fm_arg0, F_GETFL); + if (fl < 0 || (x->kind == FM_X_ARG0_GETFL ? (fl & x->a) != x->a + : (fl & x->a) != 0)) + return WHY("F_GETFL=%#x, bits %#lx", fl, x->a), 0; + return 1; + case FM_X_MAP: + case FM_X_MAP_AT_REGION: + case FM_X_MAP_ELSEWHERE: + case FM_X_MAP_WRITABLE: + case FM_X_MAP_READONLY: + case FM_X_MAP_NOREAD: + case FM_X_MAP_WRITES_FILE: + case FM_X_MAP_KEEPS_FILE: { + char *p = (char *) rc; + if (!is_mapping(rc)) + return WHY("rc=%ld, want an address", rc), 0; + if (x->kind == FM_X_MAP) + return 1; + if (x->kind == FM_X_MAP_AT_REGION || x->kind == FM_X_MAP_ELSEWHERE) { + if ((p == fm_region) != (x->kind == FM_X_MAP_AT_REGION)) + return WHY("mapped at %p, region is %p", (void *) p, + (void *) fm_region), + 0; + return 1; + } + if (x->kind == FM_X_MAP_NOREAD) { + if (can_read(p)) + return WHY("the mapping is readable"), 0; + return 1; + } + if (!can_read(p)) + return WHY("the mapping is not readable"), 0; + if (x->kind == FM_X_MAP_READONLY) { + if (can_write(p)) + return WHY("the mapping is writable"), 0; + return 1; + } + if (!can_write(p)) + return WHY("the mapping is not writable"), 0; + if (x->kind == FM_X_MAP_WRITABLE) + return 1; + + /* The store above put 'Z' over the first byte. A shared mapping shows + * it to a read of the file; a private one keeps it to itself. + */ + char first = 0; + int fd = open("file", O_RDONLY); + if (fd < 0 || pread(fd, &first, 1, 0) != 1) { + if (fd >= 0) + close(fd); + return WHY("cannot read the file back, errno=%d", errno), 0; + } + close(fd); + if ((first == 'Z') != (x->kind == FM_X_MAP_WRITES_FILE)) + return WHY("the file starts with '%c'", first), 0; + return 1; + } + case FM_X_REGION_WRITABLE: + case FM_X_REGION_READONLY: + case FM_X_REGION_NOREAD: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + if (can_read(fm_region) != (x->kind != FM_X_REGION_NOREAD)) + return WHY("the region is %sreadable", + x->kind == FM_X_REGION_NOREAD ? "" : "not "), + 0; + if (x->kind != FM_X_REGION_NOREAD && + can_write(fm_region) != (x->kind == FM_X_REGION_WRITABLE)) + return WHY("the region is %swritable", + x->kind == FM_X_REGION_WRITABLE ? "not " : ""), + 0; + return 1; case FM_X_STATX_REG: case FM_X_STATX_LNK: { struct statx sx; @@ -389,6 +542,12 @@ static int run_one(const struct fm_row *row, int with, int strict) exit(2); } memset(fm_buf, 0, sizeof(fm_buf)); + fm_region = mmap(NULL, FM_REGION_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (fm_region == MAP_FAILED) { + FAIL("fixture region mmap failed"); + return 0; + } for (int i = 0; i < row->nargs; i++) { const struct fm_arg *arg = &row->args[i]; @@ -418,12 +577,16 @@ static int run_one(const struct fm_row *row, int with, int strict) a[i] = (long) fm_pair; pair = 1; break; + case FM_A_REGION: + a[i] = (long) fm_region; + break; case FM_A_FLAGS: a[i] = arg->val | (with ? row->bits : 0); break; } } + fm_arg0 = a[0]; long rc = raw_syscall6(row->nr, a[0], a[1], a[2], a[3], a[4], a[5]); /* A call that fills an fd pair answers through both of them, unless the @@ -453,7 +616,11 @@ static int run_one(const struct fm_row *row, int with, int strict) close((int) rc); } + if (x->kind >= FM_X_MAP && x->kind <= FM_X_MAP_KEEPS_FILE && is_mapping(rc)) + munmap((void *) rc, 4096); + out: + munmap(fm_region, FM_REGION_SIZE); while (nopened > 0) close((int) opened[--nopened]); if (fm_client >= 0) { @@ -467,6 +634,11 @@ int main(int argc, char **argv) { int strict = argc > 1 && strcmp(argv[1], "strict") == 0; + struct sigaction sa = {.sa_handler = on_fault}; + sigemptyset(&sa.sa_mask); + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + printf("test-flag-matrix: %zu rows\n", sizeof(fm_rows) / sizeof(fm_rows[0])); snprintf(fm_base, sizeof(fm_base), "/tmp/flag-matrix-XXXXXX"); From 800215c55be157f4e3e8734e0a9a924c20190bcf Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 02:05:33 +0800 Subject: [PATCH 10/34] Cover memfd sealing and pipe packet mode 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. --- docs/testing.md | 10 +++++++++- scripts/gen-flag-matrix.py | 3 +++ tests/flag-matrix.tbl | 9 +++++++++ tests/test-flag-matrix.c | 25 +++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 12b440fa..244bf4ac 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -549,7 +549,15 @@ difference to skip. Two marks exist for elfuse. `pending` is a divergence still to be fixed: its mismatch is reported and not failed, and the lane fails once the row passes, until the mark is removed. `unsupported:ANSWER` is a flag elfuse declines on purpose, and ANSWER has to be one Linux documents for a -kernel or filesystem without the feature. To add a syscall, add rows; the +kernel or filesystem without the feature. One row is pending by decision +rather than by backlog: `pipe2 O_DIRECT@packets`. The host pipe is a byte +stream, so an `O_DIRECT` pipe here delivers two writes in one read where Linux +delivers the first alone. Giving the pipe a framed backing would fix `pipe2` +and could not fix `F_SETFL` on a pipe that already exists; refusing the flag +with `EINVAL`, which `pipe2(2)` documents for kernels that predate it, would +undo the acceptance `test-negative` and `test-fcntl-flags` assert. Until one +of those is chosen the row reports the difference on every run. To add a +syscall, add rows; the driver has no code for any one call. A new kind of argument or answer goes into `scripts/gen-flag-matrix.py` and `tests/test-flag-matrix.c` together. diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index 892b6e17..6a6f61f2 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -59,6 +59,8 @@ "buf:lnk": "FM_X_STAT_LNK", "bufx:reg": "FM_X_STATX_REG", "bufx:lnk": "FM_X_STATX_LNK", + "pair:packets": "FM_X_PAIR_PACKETS", + "pair:stream": "FM_X_PAIR_STREAM", "arg0:cloexec": "FM_X_ARG0_CLOEXEC", "arg0:nocloexec": "FM_X_ARG0_NOCLOEXEC", "map": "FM_X_MAP", @@ -79,6 +81,7 @@ "errkeeps": "FM_X_ERR_KEEPS", "fd:getfl": "FM_X_FD_GETFL", "fd:nogetfl": "FM_X_FD_NOGETFL", + "fd:seals": "FM_X_FD_SEALS", "pair1:getfl": "FM_X_PAIR1_GETFL", "pair1:nogetfl": "FM_X_PAIR1_NOGETFL", "arg0:getfl": "FM_X_ARG0_GETFL", diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 92f3952a..523a26e5 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -45,6 +45,7 @@ # fd:getfl:EXPR fd:nogetfl:EXPR F_GETFL has every bit, or no bit, of EXPR # pair1:getfl:EXPR pair1:nogetfl:EXPR the same, asked only of the second # fd of a pair +# fd:seals:EXPR F_GET_SEALS on the fd returns exactly EXPR # fd:appends fd:overwrites a 1-byte write at offset 0 grows the file, # or leaves its size alone # fd:readebadf read on the fd gives EBADF @@ -64,6 +65,8 @@ # a read of "file" # region:writable region:readonly region:noread # the same loads and stores, on the region +# pair:packets pair:stream one read after two writes returns the +# first write alone, or both # = (last field only) the same answer as with the flag # # A row that starts with "unsupported:ANSWER" is a flag elfuse declines on @@ -165,6 +168,10 @@ pipe2 | O_CLOEXEC | pair F | fd:cloexec | fd:nocloexec pipe2 | O_NONBLOCK | pair F | fd:getfl:O_NONBLOCK | fd:nogetfl:O_NONBLOCK # Packet mode is a property of the write end; the read end never reports it. pipe2 | O_DIRECT | pair F | pair1:getfl:O_DIRECT | pair1:nogetfl:O_DIRECT +# Packet mode itself. The host pipe is a byte stream and has no such mode, so +# this stays pending until someone decides between giving the pipe a framed +# backing and refusing the flag; see the note in docs/testing.md. +pending pipe2 | O_DIRECT@packets | pair F | pair:packets | pair:stream pipe2 | O_APPEND@unknown | pair F | err:EINVAL | fd # eventfd2(initval, flags) @@ -197,6 +204,8 @@ memfd_create | MFD_CLOEXEC | p:file F | fd:cloexec | fd:nocloexec memfd_create | 0x8000@unknown | p:file F | err:EINVAL | fd # MFD_NOEXEC_SEAL and MFD_EXEC contradict each other. memfd_create | 0x10@exec-and-noexec | p:file F|0x8 | err:EINVAL | fd +memfd_create | MFD_ALLOW_SEALING | p:file F | fd:seals:0 | fd:seals:F_SEAL_SEAL +memfd_create | MFD_HUGETLB | p:file F | fd | = # socket(domain, type, protocol): the flags share the type argument. socket | SOCK_CLOEXEC | AF_UNIX F|SOCK_STREAM 0 | fd:cloexec | fd:nocloexec diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index 15ae7995..faf2a3b1 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -81,6 +81,7 @@ enum fm_expect_kind { FM_X_PAIR1_NOGETFL, FM_X_FD_APPENDS, FM_X_FD_OVERWRITES, + FM_X_FD_SEALS, FM_X_FD_READ_EBADF, FM_X_EXISTS, FM_X_ABSENT, @@ -107,6 +108,8 @@ enum fm_expect_kind { FM_X_REGION_WRITABLE, FM_X_REGION_READONLY, FM_X_REGION_NOREAD, + FM_X_PAIR_PACKETS, + FM_X_PAIR_STREAM, }; struct fm_arg { @@ -351,6 +354,13 @@ static int answer_matches(const struct fm_expect *x, long rc) return WHY("size %ld after a write at offset 0", (long) st.st_size), 0; return 1; + case FM_X_FD_SEALS: + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + fl = fcntl((int) rc, F_GET_SEALS); + if (fl != (int) x->a) + return WHY("F_GET_SEALS=%#x, want %#lx", fl, x->a), 0; + return 1; case FM_X_FD_READ_EBADF: if (rc < 0) return WHY("rc=%ld, want an fd", rc), 0; @@ -490,6 +500,21 @@ static int answer_matches(const struct fm_expect *x, long rc) x->kind == FM_X_REGION_WRITABLE ? "not " : ""), 0; return 1; + case FM_X_PAIR_PACKETS: + case FM_X_PAIR_STREAM: { + /* Two writes of two bytes, then one read of four. A packet pipe hands + * back the first write alone; a stream hands back both. + */ + char got[4]; + if (rc != 0) + return WHY("rc=%ld, want 0", rc), 0; + if (write(fm_pair[1], "ab", 2) != 2 || write(fm_pair[1], "cd", 2) != 2) + return WHY("probe write failed, errno=%d", errno), 0; + ssize_t n = read(fm_pair[0], got, sizeof(got)); + if (n != (x->kind == FM_X_PAIR_PACKETS ? 2 : 4)) + return WHY("one read returned %zd bytes", n), 0; + return 1; + } case FM_X_STATX_REG: case FM_X_STATX_LNK: { struct statx sx; From fd3da6a51820ca5a96234a3a293cab6bca03425e Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:03:36 +0800 Subject: [PATCH 11/34] Refuse an mmap with no valid mapping type 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. --- src/syscall/linux-wire.h | 15 +++++++++++++++ src/syscall/mem.c | 27 +++++++++++++++++++++++++-- src/syscall/syscall.c | 12 +++++++++++- tests/flag-matrix.tbl | 18 ++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 84ccd09c..7426cc72 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -324,11 +324,26 @@ typedef struct { #define LINUX_MAP_SHARED 0x01 #define LINUX_MAP_PRIVATE 0x02 +#define LINUX_MAP_SHARED_VALIDATE 0x03 +#define LINUX_MAP_TYPE 0x0f #define LINUX_MAP_FIXED 0x10 #define LINUX_MAP_ANONYMOUS 0x20 #define LINUX_MAP_NORESERVE 0x4000 #define LINUX_MAP_FIXED_NOREPLACE 0x100000 +/* Every flag a MAP_SHARED_VALIDATE mapping may carry: Linux's LEGACY_MAP_MASK + * (include/linux/mman.h), which is DENYWRITE, EXECUTABLE, GROWSDOWN, LOCKED, + * NORESERVE, POPULATE, NONBLOCK, STACK, HUGETLB, the three named above, and of + * the huge page size field the five bits MAP_HUGE_2MB and MAP_HUGE_1GB cover + * between them. Bit 31 is the field's sixth bit and is not in the mask, and + * neither is MAP_FIXED_NOREPLACE, so both are EOPNOTSUPP like MAP_SYNC, which + * needs a DAX file, and like the bits nothing defines. + */ +#define LINUX_MAP_VALIDATE_MASK \ + (LINUX_MAP_TYPE | LINUX_MAP_FIXED | LINUX_MAP_ANONYMOUS | 0x0100 | \ + 0x0800 | 0x1000 | 0x2000 | LINUX_MAP_NORESERVE | 0x8000 | 0x10000 | \ + 0x20000 | 0x40000 | (0x1f << 26)) + /* Linux msync flags. */ #define LINUX_MS_ASYNC 0x1 #define LINUX_MS_INVALIDATE 0x2 diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 43dda4f4..9d4ed307 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -2918,13 +2918,36 @@ int64_t sys_mmap(guest_t *g, * (CLONE_VM semantics). */ + /* Linux requires page-aligned offset for file-backed mmap */ + if (!is_anon && (offset & 4095)) + return -LINUX_EINVAL; + + /* Linux looks the descriptor up before it judges the length or the flags, + * so a bad one is EBADF whatever else is wrong with the call. + */ + if (!is_anon) { + fd_entry_t snap; + if (!fd_snapshot(fd, &snap)) + return -LINUX_EBADF; + } + /* Linux rejects zero-length mmap */ if (length == 0) return -LINUX_EINVAL; - /* Linux requires page-aligned offset for file-backed mmap */ - if (!is_anon && (offset & 4095)) + /* The mapping type is MAP_SHARED, MAP_PRIVATE or, for a file only, the two + * together as MAP_SHARED_VALIDATE. Linux refuses anything else, and a + * mapping with neither used to be made here as a private one. Under + * MAP_SHARED_VALIDATE a flag the file cannot honor is EOPNOTSUPP rather + * than ignored, which is the whole point of asking for validation. + */ + int map_type = flags & LINUX_MAP_TYPE; + if (map_type != LINUX_MAP_SHARED && map_type != LINUX_MAP_PRIVATE && + (map_type != LINUX_MAP_SHARED_VALIDATE || is_anon)) return -LINUX_EINVAL; + if (map_type == LINUX_MAP_SHARED_VALIDATE && + (flags & ~LINUX_MAP_VALIDATE_MASK)) + return -LINUX_EOPNOTSUPP; if (!is_anon && fuse_fd_refuse_mmap(fd)) { bool allow_materialized_fuse_mmap = diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index d90e0b46..00dc20ef 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -985,8 +985,18 @@ static int64_t sc_mmap(guest_t *g, uint64_t x5, bool verbose) { + /* Linux takes the flags as a long, and MAP_SHARED_VALIDATE refuses a bit + * above 31 like any other it does not know, where sys_mmap works on an int. + * Bit 31 is outside the validation mask and has no meaning anywhere else in + * sys_mmap, so it stands in for all of them. + */ + uint32_t flags = (uint32_t) x3; + if (x3 >> 32) + flags |= UINT32_C(1) << 31; + mmap_lock_acquire(); - int64_t r = sys_mmap(g, x0, x1, (int) x2, (int) x3, (int) x4, (int64_t) x5); + int64_t r = + sys_mmap(g, x0, x1, (int) x2, (int) flags, (int) x4, (int64_t) x5); mmap_lock_release(); log_debug(" mmap(0x%llx, 0x%llx) \xe2\x86\x92 0x%llx", (unsigned long long) x0, (unsigned long long) x1, diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 523a26e5..f642ccab 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -233,11 +233,29 @@ fcntl | O_SYNC@setfl-ignored | fd:file F_SETFL F | arg0:nogetfl:O_SYNC | = # mmap(addr, length, prot, flags, fd, offset): the flags are argument 3. mmap | MAP_ANONYMOUS | 0 4096 PROT_READ F|MAP_PRIVATE -1 0 | map | err:EBADF +# The descriptor is looked up before the mapping type is judged. +mmap | MAP_PRIVATE@bad-fd-first | 0 4096 PROT_READ F -1 0 | err:EBADF | = +mmap | MAP_PRIVATE@neither | 0 4096 PROT_READ F|MAP_ANONYMOUS -1 0 | map | err:EINVAL +mmap | MAP_SHARED@neither | 0 4096 PROT_READ F|MAP_ANONYMOUS -1 0 | map | err:EINVAL mmap | MAP_FIXED | region 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:atregion | map:elsewhere mmap | MAP_FIXED_NOREPLACE | region 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | err:EEXIST | map:elsewhere # MAP_SHARED beside MAP_PRIVATE is MAP_SHARED_VALIDATE, which is shared. mmap | MAP_SHARED@file | 0 4096 PROT_READ|PROT_WRITE F|MAP_PRIVATE fd:file 0 | map:writesfile | map:keepsfile +# MAP_SHARED_VALIDATE refuses a flag the file cannot honor; MAP_SYNC (0x80000) +# needs DAX. Plain MAP_SHARED ignores the same bit. +mmap | 0x80000@sync-validate | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map +# What it accepts is LEGACY_MAP_MASK, which leaves out MAP_FIXED_NOREPLACE and +# the top bit of the huge page size field (bit 31). The other five bits of the +# field are in it, and without MAP_HUGETLB they mean nothing. The flags are a +# long, so a bit above 31 is refused the same way. +mmap | 0x80000000@validate-bit31 | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map +mmap | 0x100000000@validate-bit32 | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map +mmap | MAP_FIXED_NOREPLACE@validate | 0x10000 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map +mmap | 0x7c000000@validate-huge-size | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | map | = +# An anonymous mapping takes MAP_SHARED or MAP_PRIVATE and not the two at once. +mmap | MAP_SHARED@anon-validate | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | err:EINVAL | map mmap | 0x80000@sync-legacy | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | map | = +mmap | 0x100000000@bit32-legacy | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | map | = mmap | MAP_POPULATE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = mmap | MAP_NORESERVE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = mmap | MAP_STACK | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = From c3b78d9c75f31b5c23f75ef7b74ffa89f6b9b8d6 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:08:09 +0800 Subject: [PATCH 12/34] Refuse the mprotect bits Linux refuses 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. --- scripts/gen-flag-matrix.py | 4 ++++ src/syscall/linux-wire.h | 5 ++++ src/syscall/mem.c | 36 ++++++++++++++++++++++++++++ tests/flag-matrix.tbl | 14 +++++++++++ tests/test-flag-matrix.c | 49 +++++++++++++++++++++++++++++++++++++- 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index 6a6f61f2..73cbd518 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -61,6 +61,8 @@ "bufx:lnk": "FM_X_STATX_LNK", "pair:packets": "FM_X_PAIR_PACKETS", "pair:stream": "FM_X_PAIR_STREAM", + "stack:lowreadonly": "FM_X_STACK_LOW_READONLY", + "stack:lowwritable": "FM_X_STACK_LOW_WRITABLE", "arg0:cloexec": "FM_X_ARG0_CLOEXEC", "arg0:nocloexec": "FM_X_ARG0_NOCLOEXEC", "map": "FM_X_MAP", @@ -130,6 +132,8 @@ def parse_arg(where: str, token: str) -> tuple[str, bool]: return "{FM_A_PAIR, 0}", False if token == "region": return "{FM_A_REGION, 0}", False + if token == "stackpage": + return "{FM_A_STACK, 0}", False if token.startswith("p:"): return f"{{FM_A_PATH, {path_token(where, token[2:])}}}", False if token.startswith("fd:"): diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 7426cc72..52313015 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -321,6 +321,11 @@ typedef struct { #define LINUX_PROT_READ 0x1 #define LINUX_PROT_WRITE 0x2 #define LINUX_PROT_EXEC 0x4 +#define LINUX_PROT_SEM 0x8 +#define LINUX_PROT_BTI 0x10 /* arm64 */ +#define LINUX_PROT_MTE 0x20 /* arm64 */ +#define LINUX_PROT_GROWSDOWN 0x01000000 +#define LINUX_PROT_GROWSUP 0x02000000 #define LINUX_MAP_SHARED 0x01 #define LINUX_MAP_PRIVATE 0x02 diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 9d4ed307..6f07a2db 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -4624,10 +4624,46 @@ static bool mprotect_same_prot_fast_path_safe(int prot) int64_t sys_mprotect(guest_t *g, uint64_t addr, uint64_t length, int prot) { + /* The two grow bits are a request about the range, not a protection, and + * Linux takes them off before it looks at anything else. Left in, they were + * recorded as part of the region's protection. + */ + const int grows = prot & (LINUX_PROT_GROWSDOWN | LINUX_PROT_GROWSUP); + prot &= ~grows; + if (grows == (LINUX_PROT_GROWSDOWN | LINUX_PROT_GROWSUP)) + return -LINUX_EINVAL; + if (addr & 4095) return -LINUX_EINVAL; if (length == 0) return 0; + + /* arm64 admits PROT_BTI and PROT_MTE only on a CPU that has the feature, + * and the guest is told of neither (no HWCAP2_BTI or HWCAP2_MTE), so both + * are EINVAL as they are on such a CPU. + */ + if (prot & ~(LINUX_PROT_READ | LINUX_PROT_WRITE | LINUX_PROT_EXEC | + LINUX_PROT_SEM)) + return -LINUX_EINVAL; + + /* PROT_GROWSDOWN is only valid on a mapping that grows down, which here is + * the main stack; glibc uses it to make the stack executable and falls back + * to plain mprotect on EINVAL. No arm64 mapping grows up. On the stack the + * bit means "from the mapping's low end up to the end of this range", so + * the start moves down to the first page above the guard. + */ + if (grows == LINUX_PROT_GROWSUP) + return -LINUX_EINVAL; + if (grows == LINUX_PROT_GROWSDOWN) { + uint64_t off = addr - g->ipa_base; + uint64_t low = g->stack_base + STACK_GUARD_SIZE; + if (addr < g->ipa_base || off < low || off >= g->stack_top) + return -LINUX_EINVAL; + if (length > UINT64_MAX - (off - low)) + return -LINUX_EINVAL; + length += off - low; + addr = g->ipa_base + low; + } length = PAGE_ALIGN_UP(length); if (length == 0) return -LINUX_EINVAL; diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index f642ccab..f0d67020 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -25,6 +25,8 @@ # pair an int[2] the call fills with two fds; an fd answer is # then asked of both # region the address of a fresh anonymous read-write mapping +# stackpage a 64 KiB span of the caller's own stack, below every +# frame in use # F F|EXPR the flags slot; EXPR is ORed in for both runs # anything else a C integer expression # @@ -67,6 +69,8 @@ # the same loads and stores, on the region # pair:packets pair:stream one read after two writes returns the # first write alone, or both +# stack:lowreadonly stack:lowwritable +# a store into the stack beneath stackpage faults, or works # = (last field only) the same answer as with the flag # # A row that starts with "unsupported:ANSWER" is a flag elfuse declines on @@ -264,3 +268,13 @@ mmap | MAP_STACK | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = # mprotect(addr, length, prot) mprotect | PROT_READ | region 4096 F | region:readonly | region:noread mprotect | PROT_WRITE | region 4096 F|PROT_READ | region:writable | region:readonly +mprotect | PROT_GROWSDOWN | region 4096 F|PROT_READ | err:EINVAL | region:readonly +# On the stack the bit extends the change down to the mapping's low end. +mprotect | PROT_GROWSDOWN@stack | stackpage 65536 F|PROT_READ | stack:lowreadonly | stack:lowwritable +mprotect | 0x10000000@unknown | region 4096 F|PROT_READ | err:EINVAL | region:readonly +# arm64 admits PROT_BTI and PROT_MTE only on a CPU with the feature. The qemu +# guest is shown BTI where the host has it, and elfuse's guest is shown neither +# (ID_AA64PFR1_EL1 reads 0), so elfuse answers as a kernel on a CPU without BTI. +unsupported:err:EINVAL mprotect | 0x10@prot-bti | region 4096 F|PROT_READ | region:readonly | = +mprotect | 0x20@prot-mte | region 4096 F|PROT_READ | err:EINVAL | region:readonly +mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index faf2a3b1..32bb3680 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -56,6 +56,7 @@ enum fm_arg_kind { FM_A_BUF, FM_A_PAIR, FM_A_REGION, + FM_A_STACK, FM_A_FLAGS, }; enum fm_path { @@ -110,6 +111,8 @@ enum fm_expect_kind { FM_X_REGION_NOREAD, FM_X_PAIR_PACKETS, FM_X_PAIR_STREAM, + FM_X_STACK_LOW_READONLY, + FM_X_STACK_LOW_WRITABLE, }; struct fm_arg { @@ -187,6 +190,25 @@ static int can_write(volatile char *p) return 1; } +/* A 64 KiB-aligned span of this thread's own stack that nothing is using: far + * enough below the live frames that a row may take write permission away from + * it and from everything beneath it, and with touched pages beneath it so that + * those belong to the stack mapping already. The signal frame of a probe that + * faults lands just under the live frames, well above the span. + */ +#define FM_STACK_DEPTH (256 * 1024) +#define FM_STACK_SPAN 65536 +static char *fm_stack_page; + +static void __attribute__((noinline)) stack_prepare(void) +{ + volatile char deep[FM_STACK_DEPTH]; + for (size_t off = 0; off < sizeof(deep); off += 4096) + deep[off] = 0; + fm_stack_page = + (char *) (((unsigned long) deep + 2 * FM_STACK_SPAN) & ~0xffffUL); +} + /* A raw mmap return is an address unless it is one of the top 4095 values. */ static int is_mapping(long rc) { @@ -515,6 +537,16 @@ static int answer_matches(const struct fm_expect *x, long rc) return WHY("one read returned %zd bytes", n), 0; return 1; } + case FM_X_STACK_LOW_READONLY: + case FM_X_STACK_LOW_WRITABLE: + if (rc < 0) + return WHY("rc=%ld, want >= 0", rc), 0; + if (can_write(fm_stack_page - FM_STACK_SPAN) != + (x->kind == FM_X_STACK_LOW_WRITABLE)) + return WHY("the stack below the range is %swritable", + x->kind == FM_X_STACK_LOW_WRITABLE ? "not " : ""), + 0; + return 1; case FM_X_STATX_REG: case FM_X_STATX_LNK: { struct statx sx; @@ -552,7 +584,7 @@ static int run_one(const struct fm_row *row, int with, int strict) x = &row->elfuse_with; long a[FM_MAX_ARGS] = {0}; long opened[FM_MAX_ARGS]; - int nopened = 0, matched = 0, pair = 0; + int nopened = 0, matched = 0, pair = 0, used_stack = 0; snprintf(label, sizeof(label), "%s %s %s", row->sys, row->flag, with ? "set" : "clear"); @@ -567,6 +599,7 @@ static int run_one(const struct fm_row *row, int with, int strict) exit(2); } memset(fm_buf, 0, sizeof(fm_buf)); + stack_prepare(); fm_region = mmap(NULL, FM_REGION_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (fm_region == MAP_FAILED) { @@ -605,6 +638,10 @@ static int run_one(const struct fm_row *row, int with, int strict) case FM_A_REGION: a[i] = (long) fm_region; break; + case FM_A_STACK: + a[i] = (long) fm_stack_page; + used_stack = 1; + break; case FM_A_FLAGS: a[i] = arg->val | (with ? row->bits : 0); break; @@ -644,6 +681,16 @@ static int run_one(const struct fm_row *row, int with, int strict) if (x->kind >= FM_X_MAP && x->kind <= FM_X_MAP_KEEPS_FILE && is_mapping(rc)) munmap((void *) rc, 4096); + /* Give the stack back whatever the row did to it, with the grow bit so that + * everything beneath the span comes back too, and without it in case the + * bit was refused. + */ + if (used_stack) { + mprotect(fm_stack_page, FM_STACK_SPAN, + PROT_READ | PROT_WRITE | PROT_GROWSDOWN); + mprotect(fm_stack_page, FM_STACK_SPAN, PROT_READ | PROT_WRITE); + } + out: munmap(fm_region, FM_REGION_SIZE); while (nopened > 0) From 9f7e0ff4f4ad1cbce2a6a43b1f9fac08517efb88 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:14:10 +0800 Subject: [PATCH 13/34] Make a fresh PROT_READ mapping read-only 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. --- src/syscall/mem.c | 19 +++++++++++++++---- tests/flag-matrix.tbl | 3 +++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 6f07a2db..981e777b 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -3461,7 +3461,16 @@ int64_t sys_mmap(guest_t *g, * so the memory syscall layer must re-create them with the * correct permissions. */ - guest_update_perms(g, result_off, result_off + length, ext_perms); + /* The block is extended read-write because it is shared with + * whatever else lands in it, but the range itself gets what was + * asked for. A PROT_READ mapping used to keep the block's write + * permission until some later mprotect, so a store into it + * succeeded where Linux raises SIGSEGV, and /proc/self/maps said + * r--p throughout. + */ + int range_perms = + (prot & LINUX_PROT_WRITE) ? ext_perms : prot_to_perms(prot); + guest_update_perms(g, result_off, result_off + length, range_perms); if (ext_end > g->mmap_end) g->mmap_end = ext_end; } @@ -4614,9 +4623,11 @@ int64_t sys_munmap(guest_t *g, uint64_t addr, uint64_t length) static bool mprotect_same_prot_fast_path_safe(int prot) { - /* Non-fixed main-arena mmap initially installs RW PTEs for PROT_READ - * mappings, relying on mprotect to tighten them later. Do not trust the - * region tracker alone for read-only same-prot requests. + /* Non-fixed main-arena mmap used to install RW PTEs for PROT_READ mappings + * and rely on mprotect to tighten them. sys_mmap now gives such a range + * read-only PTEs itself, but this stays conservative: a read-only same-prot + * request still does the PTE work rather than trusting the region tracker + * alone. */ return prot == LINUX_PROT_NONE || (prot & LINUX_PROT_WRITE) || (prot & LINUX_PROT_EXEC); diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index f0d67020..b3672284 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -264,6 +264,9 @@ mmap | MAP_POPULATE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | mmap | MAP_NORESERVE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = mmap | MAP_STACK | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = +# mmap again, for the protection bits in argument 2. +mmap | PROT_READ | 0 65536 F MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:readonly | map:noread +mmap | PROT_WRITE | 0 65536 F|PROT_READ MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:writable | map:readonly # mprotect(addr, length, prot) mprotect | PROT_READ | region 4096 F | region:readonly | region:noread From a879ab9f888f65482600e72c091b541a8bef4b16 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 02:10:34 +0800 Subject: [PATCH 14/34] Refuse an unaligned offset on anonymous mmap 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. --- src/syscall/mem.c | 6 ++++-- tests/flag-matrix.tbl | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 981e777b..bd4ab373 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -2918,8 +2918,10 @@ int64_t sys_mmap(guest_t *g, * (CLONE_VM semantics). */ - /* Linux requires page-aligned offset for file-backed mmap */ - if (!is_anon && (offset & 4095)) + /* The arm64 mmap entry refuses an offset that is not page aligned before it + * looks at anything else, anonymous mappings included. + */ + if (offset & 4095) return -LINUX_EINVAL; /* Linux looks the descriptor up before it judges the length or the flags, diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index b3672284..0405491c 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -263,6 +263,8 @@ mmap | 0x100000000@bit32-legacy | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | map mmap | MAP_POPULATE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = mmap | MAP_NORESERVE | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = mmap | MAP_STACK | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map | = +# The offset has to be page aligned even when no file is involved. +mmap | MAP_POPULATE@odd-offset | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 1 | err:EINVAL | = # mmap again, for the protection bits in argument 2. mmap | PROT_READ | 0 65536 F MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:readonly | map:noread From 3c14a5fb730912af86ba6aea491cfd2e2e4dfac0 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 04:09:15 +0800 Subject: [PATCH 15/34] Refuse MAP_HUGETLB on a file mapping 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. --- src/syscall/fs.c | 2 +- src/syscall/linux-wire.h | 9 ++++++++- src/syscall/mem.c | 7 +++++++ src/syscall/syscall.c | 2 ++ tests/flag-matrix.tbl | 3 +++ tests/test-fd-lifecycle.c | 42 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 98878c89..75c57f39 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2083,7 +2083,7 @@ int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) /* macOS does not support pipe size setting; pretend success */ return (int64_t) arg; case LINUX_F_GET_SEALS: - return fd_table[fd].seals; + return fd_table[fd].seals & ~FD_SEALS_MFD_HUGETLB; case LINUX_F_ADD_SEALS: { host_fd_ref_t host_ref; int64_t ref_err = host_fd_ref_open(fd, &host_ref); diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 52313015..b31d2dc5 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -334,6 +334,7 @@ typedef struct { #define LINUX_MAP_FIXED 0x10 #define LINUX_MAP_ANONYMOUS 0x20 #define LINUX_MAP_NORESERVE 0x4000 +#define LINUX_MAP_HUGETLB 0x40000 #define LINUX_MAP_FIXED_NOREPLACE 0x100000 /* Every flag a MAP_SHARED_VALIDATE mapping may carry: Linux's LEGACY_MAP_MASK @@ -347,7 +348,7 @@ typedef struct { #define LINUX_MAP_VALIDATE_MASK \ (LINUX_MAP_TYPE | LINUX_MAP_FIXED | LINUX_MAP_ANONYMOUS | 0x0100 | \ 0x0800 | 0x1000 | 0x2000 | LINUX_MAP_NORESERVE | 0x8000 | 0x10000 | \ - 0x20000 | 0x40000 | (0x1f << 26)) + 0x20000 | LINUX_MAP_HUGETLB | (0x1f << 26)) /* Linux msync flags. */ #define LINUX_MS_ASYNC 0x1 @@ -562,6 +563,12 @@ typedef struct { #define LINUX_F_SEAL_WRITE 0x0008 #define LINUX_F_SEAL_FUTURE_WRITE 0x0010 +/* Not a seal: elfuse's mark, kept beside the seals so that dup and fork carry + * it, on a memfd made with MFD_HUGETLB. Linux treats such a file as hugetlbfs + * and lets mmap ask for MAP_HUGETLB on it. F_GET_SEALS leaves it out. + */ +#define FD_SEALS_MFD_HUGETLB 0x40000000 + /* memfd_create flags (MFD_*). */ #define LINUX_MFD_CLOEXEC 0x0001U #define LINUX_MFD_ALLOW_SEALING 0x0002U diff --git a/src/syscall/mem.c b/src/syscall/mem.c index bd4ab373..60cce65c 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -2931,6 +2931,13 @@ int64_t sys_mmap(guest_t *g, fd_entry_t snap; if (!fd_snapshot(fd, &snap)) return -LINUX_EBADF; + + /* MAP_HUGETLB on a file asks for a hugetlbfs one, and Linux answers + * EINVAL for any other (ksys_mmap_pgoff). The only such file is an + * MFD_HUGETLB memfd, backed here by ordinary pages like the mapping. + */ + if ((flags & LINUX_MAP_HUGETLB) && !(snap.seals & FD_SEALS_MFD_HUGETLB)) + return -LINUX_EINVAL; } /* Linux rejects zero-length mmap */ diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 00dc20ef..68d2974e 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1915,6 +1915,8 @@ static int64_t sc_memfd_create(guest_t *g, LINUX_O_RDWR | ((flags & LINUX_MFD_CLOEXEC) ? LINUX_O_CLOEXEC : 0)); fd_table[gfd].seals = (flags & LINUX_MFD_ALLOW_SEALING) ? 0 : LINUX_F_SEAL_SEAL; + if (flags & LINUX_MFD_HUGETLB) + fd_table[gfd].seals |= FD_SEALS_MFD_HUGETLB; return gfd; } diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 0405491c..d13e5ae9 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -256,6 +256,9 @@ mmap | 0x80000000@validate-bit31 | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:fil mmap | 0x100000000@validate-bit32 | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map mmap | MAP_FIXED_NOREPLACE@validate | 0x10000 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EOPNOTSUPP | map mmap | 0x7c000000@validate-huge-size | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | map | = +# MAP_HUGETLB on a file asks for a hugetlbfs file, whatever the mapping type. +mmap | MAP_HUGETLB@file | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | err:EINVAL | map +mmap | MAP_HUGETLB@file-validate | 0 4096 PROT_READ F|MAP_SHARED_VALIDATE fd:file 0 | err:EINVAL | map # An anonymous mapping takes MAP_SHARED or MAP_PRIVATE and not the two at once. mmap | MAP_SHARED@anon-validate | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 0 | err:EINVAL | map mmap | 0x80000@sync-legacy | 0 4096 PROT_READ F|MAP_SHARED fd:file 0 | map | = diff --git a/tests/test-fd-lifecycle.c b/tests/test-fd-lifecycle.c index c4f28519..7fbe172c 100644 --- a/tests/test-fd-lifecycle.c +++ b/tests/test-fd-lifecycle.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,14 @@ #define F_ADD_SEALS 1033 #endif +#ifndef F_GET_SEALS +#define F_GET_SEALS 1034 +#endif + +#ifndef MAP_HUGETLB +#define MAP_HUGETLB 0x40000 +#endif + #ifndef F_SEAL_WRITE #define F_SEAL_WRITE 0x0008 #endif @@ -62,6 +71,38 @@ static void test_memfd_accepts_valid_linux_flags(void) PASS(); } +/* An MFD_HUGETLB memfd is a hugetlbfs file, the one kind of file Linux lets + * MAP_HUGETLB name. Without a huge page pool the mapping can still fail with + * ENOMEM, so only EINVAL is wrong. + */ +static void test_memfd_hugetlb_maps_hugetlb(void) +{ + TEST("MAP_HUGETLB on an MFD_HUGETLB memfd"); + + int fd = (int) syscall(SYS_memfd_create, "elfuse-hugetlb", + MFD_ALLOW_SEALING | MFD_HUGETLB); + if (fd < 0) { + FAIL("memfd_create failed"); + return; + } + + int seals = fcntl(fd, F_GET_SEALS); + size_t len = 2u << 20; + void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_HUGETLB, + fd, 0); + int map_errno = p == MAP_FAILED ? errno : 0; + if (p != MAP_FAILED) + munmap(p, len); + close(fd); + + if (seals != 0) + FAIL("F_GET_SEALS reported bits that are not seals"); + else if (map_errno == EINVAL) + FAIL("mmap refused MAP_HUGETLB with EINVAL"); + else + PASS(); +} + static void test_memfd_seals_survive_dup(void) { TEST("memfd seals copied to dup"); @@ -303,6 +344,7 @@ int main(void) test_memfd_seals_propagate_to_dup(); test_memfd_seals_cleared_on_reuse(); test_memfd_accepts_valid_linux_flags(); + test_memfd_hugetlb_maps_hugetlb(); test_rlimit_nofile_reports_emfile(); test_dup3_above_rlimit_fails(); test_nofile_hides_host_reserve(); From d8daa8cf2a43e5365c99ff6d496751ff81e1f7a2 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 04:10:22 +0800 Subject: [PATCH 16/34] Treat a protection without access bits as none 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. --- src/syscall/mem.c | 17 ++++++++++++++--- tests/flag-matrix.tbl | 4 ++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 60cce65c..ceb54e05 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -2870,6 +2870,14 @@ int64_t sys_mmap(guest_t *g, int fd, int64_t offset) { + /* A protection is its three access bits. Linux drops PROT_SEM when it turns + * prot into the mapping's flags (calc_vm_prot_bits) and ignores a bit it + * does not define, and BTI and MTE are not acted on here. A request with + * none of the three is PROT_NONE whatever else it carries; kept, the other + * bits made it fail every PROT_NONE test below and come out readable. + */ + prot &= LINUX_PROT_READ | LINUX_PROT_WRITE | LINUX_PROT_EXEC; + bool is_anon = (flags & LINUX_MAP_ANONYMOUS) != 0; bool needs_exec = (prot & LINUX_PROT_EXEC) != 0; bool is_prot_none = (prot == LINUX_PROT_NONE); @@ -4658,13 +4666,16 @@ int64_t sys_mprotect(guest_t *g, uint64_t addr, uint64_t length, int prot) if (length == 0) return 0; - /* arm64 admits PROT_BTI and PROT_MTE only on a CPU that has the feature, - * and the guest is told of neither (no HWCAP2_BTI or HWCAP2_MTE), so both - * are EINVAL as they are on such a CPU. + /* PROT_SEM is accepted and not acted on. Only the access bits go further, + * so a request with none of them is PROT_NONE; see sys_mmap. arm64 admits + * PROT_BTI and PROT_MTE only on a CPU that has the feature, and the guest + * is told of neither (no HWCAP2_BTI or HWCAP2_MTE), so both are EINVAL as + * they are on such a CPU. */ if (prot & ~(LINUX_PROT_READ | LINUX_PROT_WRITE | LINUX_PROT_EXEC | LINUX_PROT_SEM)) return -LINUX_EINVAL; + prot &= LINUX_PROT_READ | LINUX_PROT_WRITE | LINUX_PROT_EXEC; /* PROT_GROWSDOWN is only valid on a mapping that grows down, which here is * the main stack; glibc uses it to make the stack executable and falls back diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index d13e5ae9..e375ad4a 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -272,10 +272,14 @@ mmap | MAP_POPULATE@odd-offset | 0 4096 PROT_READ F|MAP_PRIVATE|MAP_ANONYMOUS -1 # mmap again, for the protection bits in argument 2. mmap | PROT_READ | 0 65536 F MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:readonly | map:noread mmap | PROT_WRITE | 0 65536 F|PROT_READ MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:writable | map:readonly +# PROT_SEM (0x8) is accepted and grants nothing: alone it is PROT_NONE. +mmap | 0x8@prot-sem | 0 65536 F MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:noread | = +mmap | 0x8@prot-sem-read | 0 65536 F|PROT_READ MAP_PRIVATE|MAP_ANONYMOUS -1 0 | map:readonly | = # mprotect(addr, length, prot) mprotect | PROT_READ | region 4096 F | region:readonly | region:noread mprotect | PROT_WRITE | region 4096 F|PROT_READ | region:writable | region:readonly +mprotect | 0x8@prot-sem | region 4096 F | region:noread | = mprotect | PROT_GROWSDOWN | region 4096 F|PROT_READ | err:EINVAL | region:readonly # On the stack the bit extends the change down to the mapping's low end. mprotect | PROT_GROWSDOWN@stack | stackpage 65536 F|PROT_READ | stack:lowreadonly | stack:lowwritable From a9f75f903afcc1c7abbc306a240bb9a477c97396 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 02:23:03 +0800 Subject: [PATCH 17/34] Honor MFD_NOEXEC_SEAL and give a memfd its mode 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. --- scripts/gen-flag-matrix.py | 4 ++- src/syscall/fs.c | 53 ++++++++++++++++++++++++++------------ src/syscall/linux-wire.h | 1 + src/syscall/path.c | 19 +++++++++----- src/syscall/path.h | 8 ++++++ src/syscall/syscall.c | 23 +++++++++++++++-- tests/flag-matrix.tbl | 12 +++++++++ tests/test-flag-matrix.c | 19 +++++++++++++- 8 files changed, 112 insertions(+), 27 deletions(-) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index 73cbd518..f9f22bb1 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -44,7 +44,8 @@ "new": "FM_P_NEW", "empty": "FM_P_EMPTY", } -FDS = {"file": "FM_FD_FILE", "dir": "FM_FD_DIR", "listener": "FM_FD_LISTENER"} +FDS = {"file": "FM_FD_FILE", "dir": "FM_FD_DIR", "listener": "FM_FD_LISTENER", + "sealed": "FM_FD_SEALED"} # Expectations that take no operand. PLAIN_EXPECTS = { @@ -84,6 +85,7 @@ "fd:getfl": "FM_X_FD_GETFL", "fd:nogetfl": "FM_X_FD_NOGETFL", "fd:seals": "FM_X_FD_SEALS", + "fd:perm": "FM_X_FD_PERM", "pair1:getfl": "FM_X_PAIR1_GETFL", "pair1:nogetfl": "FM_X_PAIR1_NOGETFL", "arg0:getfl": "FM_X_ARG0_GETFL", diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 75c57f39..66f96f92 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2097,9 +2097,9 @@ int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) } int new_seals = (int) arg; /* Only allow valid seal bits */ - if (new_seals & - ~(LINUX_F_SEAL_SEAL | LINUX_F_SEAL_SHRINK | LINUX_F_SEAL_GROW | - LINUX_F_SEAL_WRITE | LINUX_F_SEAL_FUTURE_WRITE)) { + if (new_seals & ~(LINUX_F_SEAL_SEAL | LINUX_F_SEAL_SHRINK | + LINUX_F_SEAL_GROW | LINUX_F_SEAL_WRITE | + LINUX_F_SEAL_FUTURE_WRITE | LINUX_F_SEAL_EXEC)) { host_fd_ref_close(&host_ref); return -LINUX_EINVAL; } @@ -3786,22 +3786,43 @@ int64_t sys_truncate(guest_t *g, uint64_t path_gva, int64_t length) /* permissions/ownership. */ +/* fchmod on the host fd of a guest descriptor that carries seals. Linux keeps + * the seals on the inode and tests them in shmem_setattr, so every chmod that + * reaches the file meets them; here each spelling that reaches a descriptor + * (fchmod, fchmodat with AT_EMPTY_PATH, an fd magic link) comes through this. + * + * F_SEAL_EXEC holds the execute bits as they are; any other change to the mode + * is still allowed. + */ +static int64_t fchmod_sealed(int host_fd, int seals, uint32_t mode) +{ + if (seals & LINUX_F_SEAL_EXEC) { + struct stat st; + if (fstat(host_fd, &st) < 0) + return linux_errno(); + if ((st.st_mode ^ mode) & 0111) + return -LINUX_EPERM; + } + if (fchmod(host_fd, mode) < 0) + return linux_errno(); + return 0; +} + int64_t sys_fchmod(int fd, uint32_t mode) { /* O_PATH fds do not support fchmod (Linux returns EBADF) */ fd_entry_t snap; - if (fd_snapshot(fd, &snap) && snap.type == FD_PATH) + bool known = fd_snapshot(fd, &snap); + if (known && snap.type == FD_PATH) return -LINUX_EBADF; host_fd_ref_t host_ref; int64_t ref_err = host_fd_ref_open(fd, &host_ref); if (ref_err < 0) return ref_err; - if (fchmod(host_ref.fd, mode) < 0) { - host_fd_ref_close(&host_ref); - return linux_errno(); - } + + int64_t rc = fchmod_sealed(host_ref.fd, known ? snap.seals : 0, mode); host_fd_ref_close(&host_ref); - return 0; + return rc; } int64_t sys_fchmodat(guest_t *g, @@ -3850,12 +3871,9 @@ int64_t sys_fchmodat(guest_t *g, int64_t ref_err = host_dirfd_ref_open(dirfd, &ref); if (ref_err < 0) return ref_err; - if (fchmod(ref.fd, mode) < 0) { - host_fd_ref_close(&ref); - return linux_errno(); - } + int64_t empty_rc = fchmod_sealed(ref.fd, snap.seals, mode); host_fd_ref_close(&ref); - return 0; + return empty_rc; } /* Without AT_EMPTY_PATH an empty name is ENOENT whatever dirfd is; see @@ -3870,10 +3888,11 @@ int64_t sys_fchmodat(guest_t *g, */ if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) { host_fd_ref_t magic; - if (path_fd_magiclink_open(path, &magic) == 0) { - int mrc = fchmod(magic.fd, mode); + fd_entry_t magic_entry; + if (path_fd_magiclink_open_entry(path, &magic, &magic_entry) == 0) { + int64_t mrc = fchmod_sealed(magic.fd, magic_entry.seals, mode); host_fd_ref_close(&magic); - return mrc < 0 ? linux_errno() : 0; + return mrc; } } diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index b31d2dc5..c32f2b11 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -562,6 +562,7 @@ typedef struct { #define LINUX_F_SEAL_GROW 0x0004 #define LINUX_F_SEAL_WRITE 0x0008 #define LINUX_F_SEAL_FUTURE_WRITE 0x0010 +#define LINUX_F_SEAL_EXEC 0x0020 /* Not a seal: elfuse's mark, kept beside the seals so that dup and fork carry * it, on a memfd made with MFD_HUGETLB. Linux treats such a file as hugetlbfs diff --git a/src/syscall/path.c b/src/syscall/path.c index 7ad64762..38684f78 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -452,6 +452,14 @@ static inline bool magiclink_type_acts_on_host_fd(int type) } int path_fd_magiclink_open(const char *path, host_fd_ref_t *ref) +{ + fd_entry_t entry; + return path_fd_magiclink_open_entry(path, ref, &entry); +} + +int path_fd_magiclink_open_entry(const char *path, + host_fd_ref_t *ref, + fd_entry_t *entry) { ref->fd = -1; ref->lifetime = NULL; @@ -474,20 +482,19 @@ int path_fd_magiclink_open(const char *path, host_fd_ref_t *ref) * replacement while the type it was admitted on describes the object that * is gone. */ - fd_entry_t snap; if (thread_is_single_active()) { - if (!fd_snapshot(fd, &snap) || snap.host_fd < 0) + if (!fd_snapshot(fd, entry) || entry->host_fd < 0) return -1; - if (!magiclink_type_acts_on_host_fd(snap.type)) + if (!magiclink_type_acts_on_host_fd(entry->type)) return -1; - ref->fd = snap.host_fd; + ref->fd = entry->host_fd; return 0; } - int host_fd = fd_host_ref_acquire(fd, &snap, &ref->lifetime); + int host_fd = fd_host_ref_acquire(fd, entry, &ref->lifetime); if (host_fd < 0) return -1; - if (!magiclink_type_acts_on_host_fd(snap.type)) { + if (!magiclink_type_acts_on_host_fd(entry->type)) { host_fd_ref_close(ref); return -1; } diff --git a/src/syscall/path.h b/src/syscall/path.h index 34c20b52..f026bd49 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -401,3 +401,11 @@ int path_fd_magiclink_guest_fd(const char *path); * between. */ int path_fd_magiclink_open(const char *path, host_fd_ref_t *ref); + +/* The same, and *entry is the table entry the reference was taken on, read + * under the same fd_lock hold. For a caller whose answer depends on what the + * descriptor carries, such as its seals. *entry is unspecified on -1. + */ +int path_fd_magiclink_open_entry(const char *path, + host_fd_ref_t *ref, + fd_entry_t *entry); diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 68d2974e..93544e4b 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1899,6 +1899,18 @@ static int64_t sc_memfd_create(guest_t *g, int fd = tmpfile_anon("memfd"); if (fd < 0) return linux_errno(); + + /* A memfd is created with every permission bit, or without the execute bits + * under MFD_NOEXEC_SEAL. The backing file has no name, so the mode is only + * what fstat reports and what F_SEAL_EXEC then holds in place. + */ + bool noexec = (flags & LINUX_MFD_NOEXEC_SEAL) != 0; + if (fchmod(fd, noexec ? 0666 : 0777) < 0) { + int saved_errno = errno; + close(fd); + errno = saved_errno; + return linux_errno(); + } int gfd = fd_alloc(FD_REGULAR, fd, NULL); if (gfd < 0) { close(fd); @@ -1913,8 +1925,15 @@ static int64_t sc_memfd_create(guest_t *g, fd_publish_linux_flags( gfd, LINUX_O_RDWR | ((flags & LINUX_MFD_CLOEXEC) ? LINUX_O_CLOEXEC : 0)); - fd_table[gfd].seals = - (flags & LINUX_MFD_ALLOW_SEALING) ? 0 : LINUX_F_SEAL_SEAL; + + /* MFD_NOEXEC_SEAL implies MFD_ALLOW_SEALING and starts with F_SEAL_EXEC + * set. MFD_EXEC asks for what is already the default. + */ + if (noexec) + fd_table[gfd].seals = LINUX_F_SEAL_EXEC; + else + fd_table[gfd].seals = + (flags & LINUX_MFD_ALLOW_SEALING) ? 0 : LINUX_F_SEAL_SEAL; if (flags & LINUX_MFD_HUGETLB) fd_table[gfd].seals |= FD_SEALS_MFD_HUGETLB; return gfd; diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index e375ad4a..2d305de3 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -22,6 +22,7 @@ # p:NAME path of a fixture entry (see below) # fd:file fd:dir a fresh O_RDWR fd on "file", an O_RDONLY fd on "dir" # fd:listener a listening Unix socket with one connection queued +# fd:sealed a memfd made with MFD_NOEXEC_SEAL: mode 0666, F_SEAL_EXEC # pair an int[2] the call fills with two fds; an fd answer is # then asked of both # region the address of a fresh anonymous read-write mapping @@ -48,6 +49,7 @@ # pair1:getfl:EXPR pair1:nogetfl:EXPR the same, asked only of the second # fd of a pair # fd:seals:EXPR F_GET_SEALS on the fd returns exactly EXPR +# fd:perm:EXPR fstat on the fd reports exactly those permission bits # fd:appends fd:overwrites a 1-byte write at offset 0 grows the file, # or leaves its size alone # fd:readebadf read on the fd gives EBADF @@ -143,6 +145,11 @@ linkat | AT_SYMLINK_FOLLOW | CWD p:link CWD p:new F | lreg:p:new | llnk:p:new # flags word in the fourth is never read. fchmodat | AT_SYMLINK_NOFOLLOW@ignored | CWD p:link 0600 F | mode:p:file=0600 | = +# fchmodat2(dirfd, path, mode, flags) +# F_SEAL_EXEC belongs to the file, so the descriptor form of chmod meets it +# too: giving the sealed memfd its execute bits is EPERM. +fchmodat2 | AT_EMPTY_PATH@exec-seal | fd:sealed p:empty 0777 F | err:EPERM | err:ENOENT + # fchownat(dirfd, path, owner, group, flags) fchownat | AT_SYMLINK_NOFOLLOW | CWD p:dangling -1 -1 F | ok | err:ENOENT fchownat | AT_EMPTY_PATH | fd:file p:empty -1 -1 F | ok | err:ENOENT @@ -209,6 +216,11 @@ memfd_create | 0x8000@unknown | p:file F | err:EINVAL | fd # MFD_NOEXEC_SEAL and MFD_EXEC contradict each other. memfd_create | 0x10@exec-and-noexec | p:file F|0x8 | err:EINVAL | fd memfd_create | MFD_ALLOW_SEALING | p:file F | fd:seals:0 | fd:seals:F_SEAL_SEAL +# MFD_NOEXEC_SEAL (0x8) allows sealing, sets F_SEAL_EXEC (0x20), and makes the +# file without its execute bits. MFD_EXEC (0x10) asks for today's default. +memfd_create | 0x8@noexec-seal | p:file F | fd:seals:0x20 | fd:seals:F_SEAL_SEAL +memfd_create | 0x8@noexec-mode | p:file F | fd:perm:0666 | fd:perm:0777 +memfd_create | 0x10@exec | p:file F | fd:perm:0777 | = memfd_create | MFD_HUGETLB | p:file F | fd | = # socket(domain, type, protocol): the flags share the type argument. diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index 32bb3680..f5e80dca 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -67,7 +67,7 @@ enum fm_path { FM_P_NEW, FM_P_EMPTY }; -enum fm_fd { FM_FD_FILE, FM_FD_DIR, FM_FD_LISTENER }; +enum fm_fd { FM_FD_FILE, FM_FD_DIR, FM_FD_LISTENER, FM_FD_SEALED }; enum fm_expect_kind { FM_X_OK, @@ -83,6 +83,7 @@ enum fm_expect_kind { FM_X_FD_APPENDS, FM_X_FD_OVERWRITES, FM_X_FD_SEALS, + FM_X_FD_PERM, FM_X_FD_READ_EBADF, FM_X_EXISTS, FM_X_ABSENT, @@ -135,6 +136,12 @@ struct fm_row { struct fm_expect elfuse_with; }; +#ifndef MFD_NOEXEC_SEAL +#define MFD_NOEXEC_SEAL 0x0008U +#endif +#ifndef __NR_fchmodat2 +#define __NR_fchmodat2 452 +#endif #include "flag-matrix-vectors.h" static const char *const fm_paths[] = { @@ -318,6 +325,8 @@ static long open_fixture_fd(long which) return open("dir", O_RDONLY | O_DIRECTORY); if (which == FM_FD_LISTENER) return open_listener(); + if (which == FM_FD_SEALED) + return memfd_create("sealed", MFD_NOEXEC_SEAL); return open("file", O_RDWR); } @@ -383,6 +392,14 @@ static int answer_matches(const struct fm_expect *x, long rc) if (fl != (int) x->a) return WHY("F_GET_SEALS=%#x, want %#lx", fl, x->a), 0; return 1; + case FM_X_FD_PERM: + if (rc < 0) + return WHY("rc=%ld, want an fd", rc), 0; + if (fstat((int) rc, &st) < 0) + return WHY("fstat failed, errno=%d", errno), 0; + if ((long) (st.st_mode & 0777) != x->a) + return WHY("mode %#o, want %#lo", (unsigned) st.st_mode, x->a), 0; + return 1; case FM_X_FD_READ_EBADF: if (rc < 0) return WHY("rc=%ld, want an fd", rc), 0; From 9b47ac63ef4dd4608959752787feb2b14d3637a7 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 00:39:56 +0800 Subject: [PATCH 18/34] Honor AT_EMPTY_PATH in faccessat2 and utimensat 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. --- scripts/gen-flag-matrix.py | 2 +- src/syscall/fs.c | 207 +++++++++++++++++++++++++++++++++---- tests/flag-matrix.tbl | 7 ++ tests/test-file-ops.c | 25 +++++ tests/test-flag-matrix.c | 15 ++- 5 files changed, 231 insertions(+), 25 deletions(-) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index f9f22bb1..1cc84df7 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -45,7 +45,7 @@ "empty": "FM_P_EMPTY", } FDS = {"file": "FM_FD_FILE", "dir": "FM_FD_DIR", "listener": "FM_FD_LISTENER", - "sealed": "FM_FD_SEALED"} + "sealed": "FM_FD_SEALED", "unlinked": "FM_FD_UNLINKED"} # Expectations that take no operand. PLAIN_EXPECTS = { diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 66f96f92..ef8bd410 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -3609,32 +3610,176 @@ int64_t sys_linkat(guest_t *g, return 0; } +/* access() on the current directory. */ +static int64_t access_cwd(int mode, int flags) +{ + proc_cwd_view_t view; + if (proc_acquire_cwd_view(&view) == 0) { + if (view.path && view.path[0] == '/' && + fuse_path_matches_mount(view.path)) { + char cwd_path[LINUX_PATH_MAX]; + str_copy_trunc(cwd_path, view.path, sizeof(cwd_path)); + proc_release_cwd_view(&view); + return fuse_access_path(cwd_path, mode, flags); + } + proc_release_cwd_view(&view); + } + int mac_flags = translate_faccessat_flags(flags); + if (faccessat(AT_FDCWD, ".", mode, mac_flags) < 0) + return linux_errno(); + return 0; +} + +/* access() by mode bits, for a descriptor the host gives no answer about; see + * host_fd_user_access. The host answers by the credentials elfuse runs with, + * whatever ids the guest reports, so those are the ones used here. + * path_check_intercept_access judges by the guest's ids, which is right only + * for a synthetic file, whose owner is reported as the guest. + */ +static bool host_access_by_mode(const struct stat *st, int mode, int flags) +{ + bool effective = (flags & LINUX_AT_EACCESS) != 0; + uid_t uid = effective ? geteuid() : getuid(); + gid_t gid = effective ? getegid() : getgid(); + + int granted = 0; + if (uid == 0) { + /* As generic_permission() has it for root: read and write always, + * execute when any execute bit is set. + */ + granted = R_OK | W_OK; + if (st->st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) + granted |= X_OK; + } else { + gid_t groups[NGROUPS_MAX]; + int ngroups = getgroups(NGROUPS_MAX, groups); + bool in_group = gid == st->st_gid; + for (int i = 0; i < ngroups && !in_group; i++) + in_group = groups[i] == st->st_gid; + + mode_t bits = st->st_mode; + if (uid == st->st_uid) + bits >>= 6; + else if (in_group) + bits >>= 3; + if (bits & 4) + granted |= R_OK; + if (bits & 2) + granted |= W_OK; + if (bits & 1) + granted |= X_OK; + } + return (mode & granted) == mode; +} + +/* What the host grants this process on a descriptor's file, as R_OK | W_OK | + * X_OK bits, or -1 when the host has no answer (a pipe or a socket carries no + * such attribute). + * + * ATTR_CMN_USERACCESS is the host's own judgement of the open file: mode bits + * and ACL entries both, by the ids the process runs with. No name is involved, + * so the answer holds for a file that has been unlinked and a rename cannot + * send it to another file. + */ +static int host_fd_user_access(int host_fd) +{ + struct attrlist attrs; + memset(&attrs, 0, sizeof(attrs)); + attrs.bitmapcount = ATTR_BIT_MAP_COUNT; + attrs.commonattr = ATTR_CMN_USERACCESS; + + struct { + uint32_t length; + uint32_t access; + } __attribute__((packed)) reply; + if (fgetattrlist(host_fd, &attrs, &reply, sizeof(reply), 0) < 0) + return -1; + return (int) (reply.access & (R_OK | W_OK | X_OK)); +} + +/* access() on the file a descriptor already names: faccessat2 with + * AT_EMPTY_PATH and an empty path. The host is asked about the descriptor + * itself. Where it has no answer the mode bits decide. + */ +static int64_t access_empty_path_fd(int dirfd, int mode, int flags) +{ + fd_entry_t snap; + if (!fd_snapshot(dirfd, &snap)) + return -LINUX_EBADF; + if (snap.type == FD_FUSE_DEV || snap.type == FD_FUSE_FILE || + snap.type == FD_FUSE_DIR) + return -LINUX_ENOSYS; + + /* A stamped descriptor stands for a synthetic file, whose mode is the + * intercept's and not that of the host fd behind it; the path-based branch + * of sys_faccessat judges such a file the same way. + */ + if (snap.proc_path[0] != '\0') { + struct stat intercepted_st; + int intercepted = + proc_intercept_stat_at(snap.proc_path, &intercepted_st, false); + if (intercepted == -1) + return linux_errno(); + if (intercepted == 0) { + if (path_check_intercept_access(&intercepted_st, mode, flags) < 0) + return linux_errno(); + return 0; + } + } + + host_fd_ref_t ref; + int64_t ref_err = host_dirfd_ref_open(dirfd, &ref); + if (ref_err < 0) + return ref_err; + + int64_t rc = 0; + struct stat fd_st; + if (mode == F_OK) { + /* The descriptor is open, so the file exists. */ + } else { + int granted = host_fd_user_access(ref.fd); + if (granted >= 0) { + if ((mode & granted) != mode) + rc = -LINUX_EACCES; + } else if (fstat(ref.fd, &fd_st) < 0) { + rc = linux_errno(); + } else if (!host_access_by_mode(&fd_st, mode, flags)) { + rc = -LINUX_EACCES; + } + } + host_fd_ref_close(&ref); + return rc; +} + int64_t sys_faccessat(guest_t *g, int dirfd, uint64_t path_gva, int mode, int flags) { + /* Linux judges the flags before it reads the path. */ + if (!validate_at_flags(flags, LINUX_AT_EACCESS | LINUX_AT_SYMLINK_NOFOLLOW | + LINUX_AT_EMPTY_PATH)) + return -LINUX_EINVAL; + + if (flags & LINUX_AT_EMPTY_PATH) { + char first; + if (guest_read_small(g, path_gva, &first, sizeof(first)) < 0) + return -LINUX_EFAULT; + if (first == '\0') { + if ((mode & ~(F_OK | R_OK | W_OK | X_OK)) != 0) + return -LINUX_EINVAL; + return dirfd == LINUX_AT_FDCWD + ? access_cwd(mode, flags) + : access_empty_path_fd(dirfd, mode, flags); + } + } + if (dirfd == LINUX_AT_FDCWD) { char dot_path[2]; if (guest_read_small(g, path_gva, dot_path, sizeof(dot_path)) == 0 && - dot_path[0] == '.' && dot_path[1] == '\0') { - proc_cwd_view_t view; - if (proc_acquire_cwd_view(&view) == 0) { - if (view.path && view.path[0] == '/' && - fuse_path_matches_mount(view.path)) { - char cwd_path[LINUX_PATH_MAX]; - str_copy_trunc(cwd_path, view.path, sizeof(cwd_path)); - proc_release_cwd_view(&view); - return fuse_access_path(cwd_path, mode, flags); - } - proc_release_cwd_view(&view); - } - int mac_flags = translate_faccessat_flags(flags); - if (faccessat(AT_FDCWD, ".", mode, mac_flags) < 0) - return linux_errno(); - return 0; - } + dot_path[0] == '.' && dot_path[1] == '\0') + return access_cwd(mode, flags); } char path[LINUX_PATH_MAX]; @@ -3645,9 +3790,6 @@ int64_t sys_faccessat(guest_t *g, if (rc < 0) return rc; - if (!validate_at_flags(flags, LINUX_AT_EACCESS | LINUX_AT_SYMLINK_NOFOLLOW)) - return -LINUX_EINVAL; - if (tx.fuse_path) return fuse_access_path(tx.intercept_path, mode, flags); @@ -4217,11 +4359,24 @@ int64_t sys_utimensat(guest_t *g, if (ref_err < 0) return ref_err; + /* AT_EMPTY_PATH with an empty path names dirfd itself, the current + * directory included. Unlike a NULL path it may carry flags. + */ + bool empty_path = false; + if (path_gva != 0 && (flags & LINUX_AT_EMPTY_PATH)) { + char first; + if (guest_read_small(g, path_gva, &first, sizeof(first)) < 0) { + host_fd_ref_close(&dir_ref); + return -LINUX_EFAULT; + } + empty_path = first == '\0'; + } + /* If path is NULL (path_gva == 0), operate on the dirfd itself */ const char *path_arg = NULL; char path[LINUX_PATH_MAX]; path_translation_t tx; - if (path_gva != 0) { + if (path_gva != 0 && !empty_path) { int64_t rc = read_translated_path(g, dirfd, path_gva, (flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW @@ -4264,7 +4419,15 @@ int64_t sys_utimensat(guest_t *g, * futimens(AT_FDCWD, ...) be invoked with macOS's AT_FDCWD sentinel (-2), * which returns EBADF and would not match Linux semantics. */ - if (!path_arg) { + if (empty_path) { + int host_rc = dir_ref.fd == AT_FDCWD + ? utimensat(AT_FDCWD, ".", times_gva ? ts : NULL, 0) + : futimens(dir_ref.fd, times_gva ? ts : NULL); + if (host_rc < 0) { + host_fd_ref_close(&dir_ref); + return linux_errno(); + } + } else if (!path_arg) { if (flags) { host_fd_ref_close(&dir_ref); return -LINUX_EINVAL; diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 2d305de3..9d1b047a 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -23,6 +23,7 @@ # fd:file fd:dir a fresh O_RDWR fd on "file", an O_RDONLY fd on "dir" # fd:listener a listening Unix socket with one connection queued # fd:sealed a memfd made with MFD_NOEXEC_SEAL: mode 0666, F_SEAL_EXEC +# fd:unlinked an O_RDWR fd on "file", which is then unlinked # pair an int[2] the call fills with two fds; an fd answer is # then asked of both # region the address of a fresh anonymous read-write mapping @@ -156,9 +157,15 @@ fchownat | AT_EMPTY_PATH | fd:file p:empty -1 -1 F | ok | err:ENOENT # utimensat(dirfd, path, times, flags) utimensat | AT_SYMLINK_NOFOLLOW | CWD p:dangling 0 F | ok | err:ENOENT +utimensat | AT_EMPTY_PATH | fd:file p:empty 0 F | ok | err:ENOENT # faccessat2(dirfd, path, mode, flags) faccessat2 | AT_SYMLINK_NOFOLLOW | CWD p:dangling F_OK F | ok | err:ENOENT +faccessat2 | AT_EMPTY_PATH | fd:file p:empty F_OK F | ok | err:ENOENT +faccessat2 | AT_EMPTY_PATH@write | fd:file p:empty W_OK F | ok | err:ENOENT +# The owner may write a file of mode 0644 after its last name is gone as well. +faccessat2 | AT_EMPTY_PATH@unlinked | fd:unlinked p:empty W_OK F | ok | err:ENOENT +faccessat2 | 0x8000@unknown | CWD p:file F_OK F | err:EINVAL | ok # AT_EACCESS matters only when the real and effective ids differ. That cannot # be arranged here either, so this row asserts that the flag is accepted. faccessat2 | AT_EACCESS | CWD p:file F_OK F | ok | = diff --git a/tests/test-file-ops.c b/tests/test-file-ops.c index 3f05cec3..a097d76f 100644 --- a/tests/test-file-ops.c +++ b/tests/test-file-ops.c @@ -39,6 +39,10 @@ #define SYS_renameat2 276 #endif +#ifndef O_PATH +#define O_PATH 010000000 +#endif + static int create_test_file(const char *path, const char *contents) { int rc = -1; @@ -169,6 +173,27 @@ int main(void) EBADF, "expected EBADF for invalid dirfd"); } + /* A descriptor on a synthetic file answers by the file's own mode, as its + * path does. Which answer that is depends on who runs the test (root may + * write a 0444 file), so the two are compared rather than fixed. + */ + TEST("faccessat2 AT_EMPTY_PATH on O_PATH /proc file matches its path"); + { + const char *proc_file = "/proc/self/status"; + int fd = open(proc_file, O_PATH); + errno = 0; + long by_fd = + fd < 0 ? -2 : syscall(SYS_faccessat2, fd, "", W_OK, AT_EMPTY_PATH); + int fd_errno = errno; + errno = 0; + long by_path = syscall(SYS_faccessat2, AT_FDCWD, proc_file, W_OK, 0); + int path_errno = errno; + if (fd >= 0) + close(fd); + EXPECT_TRUE(by_fd == by_path && fd_errno == path_errno, + "descriptor and path answers differ"); + } + /* Test hardlink */ TEST("hardlink (link)"); if (link(testfile, hardlink_path) == 0) { diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index f5e80dca..ee518e3f 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -67,7 +67,13 @@ enum fm_path { FM_P_NEW, FM_P_EMPTY }; -enum fm_fd { FM_FD_FILE, FM_FD_DIR, FM_FD_LISTENER, FM_FD_SEALED }; +enum fm_fd { + FM_FD_FILE, + FM_FD_DIR, + FM_FD_LISTENER, + FM_FD_SEALED, + FM_FD_UNLINKED +}; enum fm_expect_kind { FM_X_OK, @@ -327,7 +333,12 @@ static long open_fixture_fd(long which) return open_listener(); if (which == FM_FD_SEALED) return memfd_create("sealed", MFD_NOEXEC_SEAL); - return open("file", O_RDWR); + int fd = open("file", O_RDWR); + if (which == FM_FD_UNLINKED && fd >= 0 && unlink("file") < 0) { + close(fd); + return -1; + } + return fd; } /* Read back one answer. rc is the raw syscall return. On a mismatch the reason From cffa7336c680ce7e8faa44aa29bd951b3de92bcf Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 05:10:01 +0800 Subject: [PATCH 19/34] Move the link-to-symlink fallback into a helper 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. --- src/syscall/fs.c | 82 ++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index ef8bd410..1f79b56e 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -3524,6 +3524,45 @@ int64_t sys_symlinkat(guest_t *g, return 0; } +/* A host linkat of (old_dirfd, old_path) without AT_SYMLINK_FOLLOW has failed, + * with its errno still set. + * + * Returns 0 once new_path exists, or a negative Linux errno. + * + * Darwin's linkat(2) man page: without AT_SYMLINK_FOLLOW, hard-linking a + * symlink itself (rather than its target) "may result in some file systems + * returning an error" -- reproduced as ENOTSUP on Case-sensitive HFS+ (EPERM + * has also been reported on other filesystems/macOS versions for the same + * condition), unlike APFS which allows it. Linux allows it unconditionally, so + * recreate the same effect with a plain symlink to the same target: a new + * directory entry that resolves identically, even though it is a distinct inode + * rather than a second link to the original. + */ +static int64_t linkat_symlink_fallback(host_fd_t old_dirfd, + const char *old_path, + host_fd_t new_dirfd, + const char *new_path) +{ + if (errno != EPERM && errno != ENOTSUP && errno != EINVAL) + return linux_errno(); + + struct stat old_st; + if (fstatat(old_dirfd, old_path, &old_st, AT_SYMLINK_NOFOLLOW) < 0 || + !S_ISLNK(old_st.st_mode)) + return -LINUX_EPERM; + + char target[LINUX_PATH_MAX]; + ssize_t target_len = + readlinkat(old_dirfd, old_path, target, sizeof(target) - 1); + if (target_len < 0) + return -LINUX_EPERM; + target[target_len] = '\0'; + + if (symlinkat(target, new_dirfd, new_path) < 0) + return linux_errno(); + return 0; +} + int64_t sys_linkat(guest_t *g, int olddirfd, uint64_t oldpath_gva, @@ -3565,49 +3604,18 @@ int64_t sys_linkat(guest_t *g, */ if (old_tx.is_dev_shm) mac_flags &= ~AT_SYMLINK_FOLLOW; + int64_t rc = 0; if (linkat(old_host_dirfd, old_tx.host_path, new_host_dirfd, new_tx.host_path, mac_flags) < 0) { - /* Darwin's linkat(2) man page: without AT_SYMLINK_FOLLOW, hard-linking - * a symlink itself (rather than its target) "may result in some file - * systems returning an error" -- reproduced here as ENOTSUP on - * Case-sensitive HFS+ (EPERM has also been reported on other - * filesystems/macOS versions for the same condition), unlike APFS which - * allows it. Linux allows it unconditionally, so recreate the same - * effect with a plain symlink to the same target: a new directory entry - * that resolves identically, even though it is a distinct inode rather - * than a second link to the original. - */ - if ((errno != EPERM && errno != ENOTSUP && errno != EINVAL) || - (flags & LINUX_AT_SYMLINK_FOLLOW)) { - host_fd_ref_close(&olddir_ref); - host_fd_ref_close(&newdir_ref); - return linux_errno(); - } - - struct stat old_st; - char target[LINUX_PATH_MAX]; - ssize_t target_len; - if (fstatat(old_host_dirfd, old_tx.host_path, &old_st, - AT_SYMLINK_NOFOLLOW) < 0 || - !S_ISLNK(old_st.st_mode) || - (target_len = readlinkat(old_host_dirfd, old_tx.host_path, target, - sizeof(target) - 1)) < 0) { - host_fd_ref_close(&olddir_ref); - host_fd_ref_close(&newdir_ref); - return -LINUX_EPERM; - } - target[target_len] = '\0'; - - if (symlinkat(target, new_host_dirfd, new_tx.host_path) < 0) { - host_fd_ref_close(&olddir_ref); - host_fd_ref_close(&newdir_ref); - return linux_errno(); - } + rc = (flags & LINUX_AT_SYMLINK_FOLLOW) + ? linux_errno() + : linkat_symlink_fallback(old_host_dirfd, old_tx.host_path, + new_host_dirfd, new_tx.host_path); } host_fd_ref_close(&olddir_ref); host_fd_ref_close(&newdir_ref); - return 0; + return rc; } /* access() on the current directory. */ From 41b1d1510781aba2570963ddec5972a84ac25665 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:55:53 +0800 Subject: [PATCH 20/34] Honor AT_EMPTY_PATH in linkat 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. --- scripts/gen-flag-matrix.py | 3 +- src/syscall/fs.c | 136 ++++++++++++++++++++++++++- tests/flag-matrix.tbl | 5 + tests/test-flag-matrix.c | 5 +- tests/test-linkat-symlink-fallback.c | 24 +++++ 5 files changed, 170 insertions(+), 3 deletions(-) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index 1cc84df7..28a20e64 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -45,7 +45,8 @@ "empty": "FM_P_EMPTY", } FDS = {"file": "FM_FD_FILE", "dir": "FM_FD_DIR", "listener": "FM_FD_LISTENER", - "sealed": "FM_FD_SEALED", "unlinked": "FM_FD_UNLINKED"} + "sealed": "FM_FD_SEALED", "unlinked": "FM_FD_UNLINKED", + "procpath": "FM_FD_PROCPATH"} # Expectations that take no operand. PLAIN_EXPECTS = { diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 1f79b56e..db7639dd 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -3563,6 +3563,127 @@ static int64_t linkat_symlink_fallback(host_fd_t old_dirfd, return 0; } +/* The directory entry that names a descriptor's file: the directory, opened and + * returned, and the entry's name in it through *leaf, which points into buf. + * Returns -1 when the file has no entry left that this can find. buf needs + * LINUX_PATH_MAX bytes, and the caller closes the directory. + * + * macOS has no call that links a descriptor, so linkat with AT_EMPTY_PATH goes + * through a name, and F_GETPATH is the only source of one. Between reading that + * name and using it the guest can rename any part of it. Used as a path, it + * would let a directory on the way be exchanged for a symlink, which the host + * follows out of the tree the guest is held in, or for another directory, which + * makes the call act on a file the descriptor does not name. So the directory + * is opened with O_NOFOLLOW_ANY and held, the entry is compared with the + * descriptor's file, and the caller acts on that one entry without following + * it. What a rename can still do after the comparison is put another entry of + * the same directory under the name. + */ +static int host_fd_live_entry(int host_fd, + const struct stat *fd_st, + char *buf, + const char **leaf) +{ + if (fcntl(host_fd, F_GETPATH, buf) < 0) + return -1; + char *slash = strrchr(buf, '/'); + if (!slash || slash[1] == '\0') + return -1; + *leaf = slash + 1; + + /* The entry's directory is everything before the last slash, or "/". */ + int dir_fd; + if (slash == buf) { + dir_fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + } else { + *slash = '\0'; + dir_fd = open(buf, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW_ANY); + } + if (dir_fd < 0) + return -1; + + struct stat entry_st; + if (fstatat(dir_fd, *leaf, &entry_st, AT_SYMLINK_NOFOLLOW) < 0 || + entry_st.st_dev != fd_st->st_dev || entry_st.st_ino != fd_st->st_ino) { + close(dir_fd); + return -1; + } + return dir_fd; +} + +/* linkat with AT_EMPTY_PATH and an empty old path: give the file olddirfd names + * a second name. A file with no name left is ENOENT, which is also what Linux + * answers for a descriptor whose link count has reached zero. + */ +static int64_t linkat_empty_path(int olddirfd, + int newdirfd, + const char *newpath) +{ + /* AT_FDCWD names the current directory, and a directory cannot be + * hard-linked. + */ + if (olddirfd == LINUX_AT_FDCWD) + return -LINUX_EPERM; + + fd_entry_t snap; + if (!fd_snapshot(olddirfd, &snap)) + return -LINUX_EBADF; + if (snap.type == FD_FUSE_DEV || snap.type == FD_FUSE_FILE || + snap.type == FD_FUSE_DIR) + return -LINUX_ENOSYS; + + /* A stamped descriptor stands for a synthetic file, and its host fd for a + * staging file of elfuse's own that must not get a name in the guest tree. + * The synthetic trees are mounts of their own on Linux, which answers EXDEV + * for a link out of one. + */ + if (snap.proc_path[0] != '\0') + return -LINUX_EXDEV; + + path_translation_t new_tx; + if (path_translate_at(newdirfd, newpath, PATH_TR_CREATE | PATH_TR_NOFOLLOW, + &new_tx) < 0) + return linux_errno(); + if (new_tx.fuse_path) + return -LINUX_ENOSYS; + + host_fd_ref_t old_ref, newdir_ref; + int64_t ref_err = host_dirfd_ref_open(olddirfd, &old_ref); + if (ref_err < 0) + return ref_err; + ref_err = host_dirfd_ref_open(newdirfd, &newdir_ref); + if (ref_err < 0) { + host_fd_ref_close(&old_ref); + return ref_err; + } + + int64_t rc = 0; + char host_path[LINUX_PATH_MAX]; + const char *leaf; + struct stat fd_st; + if (fstat(old_ref.fd, &fd_st) < 0) { + rc = linux_errno(); + } else if (S_ISDIR(fd_st.st_mode)) { + rc = -LINUX_EPERM; + } else { + int entry_dir = + host_fd_live_entry(old_ref.fd, &fd_st, host_path, &leaf); + if (entry_dir < 0) + rc = -LINUX_ENOENT; + else if (linkat(entry_dir, leaf, + path_translation_dirfd(&new_tx, &newdir_ref), + new_tx.host_path, 0) < 0) + rc = linkat_symlink_fallback( + entry_dir, leaf, path_translation_dirfd(&new_tx, &newdir_ref), + new_tx.host_path); + close_keep_errno(entry_dir); + } + + host_fd_ref_close(&old_ref); + host_fd_ref_close(&newdir_ref); + return rc; +} + int64_t sys_linkat(guest_t *g, int olddirfd, uint64_t oldpath_gva, @@ -3576,9 +3697,22 @@ int64_t sys_linkat(guest_t *g, guest_read_str(g, newpath_gva, newpath, sizeof(newpath)) < 0) return -LINUX_EFAULT; - if (!validate_at_flags(flags, LINUX_AT_SYMLINK_FOLLOW)) + if (!validate_at_flags(flags, + LINUX_AT_SYMLINK_FOLLOW | LINUX_AT_EMPTY_PATH)) return -LINUX_EINVAL; + /* An empty name is ENOENT whatever its dirfd is; see read_translated_path. + * The old name alone may be empty, and only with AT_EMPTY_PATH, where it + * stands for olddirfd itself. + */ + if (newpath[0] == '\0') + return -LINUX_ENOENT; + if (oldpath[0] == '\0') { + if (!(flags & LINUX_AT_EMPTY_PATH)) + return -LINUX_ENOENT; + return linkat_empty_path(olddirfd, newdirfd, newpath); + } + unsigned int old_flags = (flags & LINUX_AT_SYMLINK_FOLLOW) ? PATH_TR_NONE : PATH_TR_NOFOLLOW; if (path_translate_at(olddirfd, oldpath, old_flags, &old_tx) < 0 || diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 9d1b047a..b3056ac2 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -141,6 +141,11 @@ statx | AT_STATX_SYNC_TYPE@both | CWD p:file F STATX_BASIC_STATS buf | err:EINVA # linkat(olddirfd, oldpath, newdirfd, newpath, flags) linkat | AT_SYMLINK_FOLLOW | CWD p:link CWD p:new F | lreg:p:new | llnk:p:new +linkat | AT_EMPTY_PATH | fd:file p:empty CWD p:new F | lreg:p:new | err:ENOENT +# A file of /proc is on a mount of its own, so it links nowhere else. +linkat | AT_EMPTY_PATH@proc | fd:procpath p:empty CWD p:new F | err:EXDEV | err:ENOENT +linkat | AT_SYMLINK_FOLLOW@empty-old | fd:file p:empty CWD p:new F | err:ENOENT | = +linkat | AT_SYMLINK_FOLLOW@empty-new | CWD p:file fd:file p:empty F | err:ENOENT | = # fchmodat(dirfd, path, mode): the kernel entry takes three arguments, so a # flags word in the fourth is never read. diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index ee518e3f..c5f99358 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -72,7 +72,8 @@ enum fm_fd { FM_FD_DIR, FM_FD_LISTENER, FM_FD_SEALED, - FM_FD_UNLINKED + FM_FD_UNLINKED, + FM_FD_PROCPATH }; enum fm_expect_kind { @@ -333,6 +334,8 @@ static long open_fixture_fd(long which) return open_listener(); if (which == FM_FD_SEALED) return memfd_create("sealed", MFD_NOEXEC_SEAL); + if (which == FM_FD_PROCPATH) + return open("/proc/self/status", O_PATH); int fd = open("file", O_RDWR); if (which == FM_FD_UNLINKED && fd >= 0 && unlink("file") < 0) { close(fd); diff --git a/tests/test-linkat-symlink-fallback.c b/tests/test-linkat-symlink-fallback.c index 01ee7a98..a674fb40 100644 --- a/tests/test-linkat-symlink-fallback.c +++ b/tests/test-linkat-symlink-fallback.c @@ -63,6 +63,30 @@ int main(void) "content mismatch"); } + /* The same source reached through a descriptor: an O_PATH fd on the symlink + * itself, linked with AT_EMPTY_PATH. + */ + TEST("linkat(AT_EMPTY_PATH) on an O_PATH fd of the symlink succeeds"); + { + int lfd = open("/d1/sym", O_PATH | O_NOFOLLOW); + EXPECT_TRUE(lfd >= 0 && linkat(lfd, "", AT_FDCWD, "/d1/hardlink-fd", + AT_EMPTY_PATH) == 0, + "linkat failed"); + if (lfd >= 0) + close(lfd); + } + + TEST("that result is a symlink to the same target"); + { + struct stat st; + char buf[64] = {0}; + ssize_t n = readlink("/d1/hardlink-fd", buf, sizeof(buf) - 1); + EXPECT_TRUE(lstat("/d1/hardlink-fd", &st) == 0 && S_ISLNK(st.st_mode) && + n == (ssize_t) strlen("target.txt") && + !strcmp(buf, "target.txt"), + "not a symlink to target.txt"); + } + SUMMARY("test-linkat-symlink-fallback"); return fails > 0 ? 1 : 0; } From 31ef112b5854148b904148eb35b15c8f05908213 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 00:43:07 +0800 Subject: [PATCH 21/34] Read the link an O_PATH descriptor names 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. --- src/syscall/fs.c | 64 ++++++++++++++++++++++++++++++++++++++++++- tests/test-file-ops.c | 26 ++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index db7639dd..5927fb8a 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2997,12 +2997,75 @@ int64_t sys_lseek(int fd, int64_t offset, int whence) /* path operations. */ +static int host_fd_live_entry(int host_fd, + const struct stat *fd_st, + char *buf, + const char **leaf); + +/* readlinkat with an empty name reads the link dirfd itself is open on, which + * only an O_PATH|O_NOFOLLOW descriptor can be. readlinkat has no AT_EMPTY_PATH: + * the empty name alone asks for this, and anything that is not a link is + * ENOENT. + * + * Returns the length of the target in link, or a negative Linux errno. + */ +static int64_t readlink_empty_path(int dirfd, char *link, size_t linksz) +{ + if (dirfd == LINUX_AT_FDCWD) + return -LINUX_ENOENT; + + fd_entry_t snap; + if (!fd_snapshot(dirfd, &snap)) + return -LINUX_EBADF; + if (snap.type != FD_PATH) + return -LINUX_ENOENT; + + /* macOS reads a link only by name, so the link goes through the entry that + * names the descriptor's file, found the way linkat_empty_path finds it. A + * link with no name left is ENOENT. + */ + host_fd_ref_t ref; + 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]; + const char *leaf; + if (fstat(ref.fd, &st) < 0) { + rc = linux_errno(); + } else if (S_ISLNK(st.st_mode)) { + int entry_dir = host_fd_live_entry(ref.fd, &st, host_path, &leaf); + if (entry_dir >= 0) { + ssize_t n = readlinkat(entry_dir, leaf, link, linksz); + rc = n < 0 ? linux_errno() : (int64_t) n; + close_keep_errno(entry_dir); + } + } + host_fd_ref_close(&ref); + return rc; +} + int64_t sys_readlinkat(guest_t *g, int dirfd, uint64_t path_gva, uint64_t buf_gva, uint64_t bufsiz) { + char link[LINUX_PATH_MAX]; + char first; + if (guest_read_small(g, path_gva, &first, sizeof(first)) < 0) + return -LINUX_EFAULT; + if (first == '\0') { + int64_t n = readlink_empty_path(dirfd, link, sizeof(link)); + if (n < 0) + return n; + size_t copy_len = (size_t) n < bufsiz ? (size_t) n : bufsiz; + if (guest_write(g, buf_gva, link, copy_len) < 0) + return -LINUX_EFAULT; + return (int64_t) copy_len; + } + char path[LINUX_PATH_MAX]; path_translation_t tx; int64_t rc = @@ -3011,7 +3074,6 @@ int64_t sys_readlinkat(guest_t *g, return rc; /* Intercept /proc paths (e.g. /proc/self/exe, /proc/self/fd/N) */ - char link[LINUX_PATH_MAX]; int intercepted = proc_intercept_readlink(tx.intercept_path, link, sizeof(link)); if (intercepted >= 0) { diff --git a/tests/test-file-ops.c b/tests/test-file-ops.c index a097d76f..b909fa8f 100644 --- a/tests/test-file-ops.c +++ b/tests/test-file-ops.c @@ -173,6 +173,32 @@ int main(void) EBADF, "expected EBADF for invalid dirfd"); } + /* An empty name reads the link an O_PATH|O_NOFOLLOW descriptor is open on, + * and is ENOENT on anything that is not a link. + */ + TEST("readlinkat empty path on O_PATH symlink fd"); + { + char buf[256]; + int fd = open(symlink_path, O_PATH | O_NOFOLLOW); + ssize_t len = + fd < 0 ? -1 : syscall(SYS_readlinkat, fd, "", buf, sizeof(buf)); + if (fd >= 0) + close(fd); + EXPECT_TRUE(len == (ssize_t) strlen(testfile) && + !memcmp(buf, testfile, (size_t) len), + "readlinkat empty path mismatch"); + } + + TEST("readlinkat empty path on a regular file"); + { + char buf[16]; + int fd = open(testfile, O_RDONLY); + EXPECT_ERRNO(syscall(SYS_readlinkat, fd, "", buf, sizeof(buf)), ENOENT, + "expected ENOENT for a descriptor that is not a link"); + if (fd >= 0) + close(fd); + } + /* A descriptor on a synthetic file answers by the file's own mode, as its * path does. Which answer that is depends on who runs the test (root may * write a 0444 file), so the two are compared rather than fixed. From 81abd0317a76f136e8cd5f2e63eb27038a050be1 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:19:36 +0800 Subject: [PATCH 22/34] Cover clone in the flag matrix 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. --- scripts/gen-flag-matrix.py | 4 +++ tests/flag-matrix.tbl | 11 ++++++++ tests/test-flag-matrix.c | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index 28a20e64..fa5acd24 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -65,6 +65,10 @@ "pair:stream": "FM_X_PAIR_STREAM", "stack:lowreadonly": "FM_X_STACK_LOW_READONLY", "stack:lowwritable": "FM_X_STACK_LOW_WRITABLE", + "child": "FM_X_CHILD", + "child:ptid": "FM_X_CHILD_PTID", + "child:noptid": "FM_X_CHILD_NOPTID", + "child:pidfd": "FM_X_CHILD_PIDFD", "arg0:cloexec": "FM_X_ARG0_CLOEXEC", "arg0:nocloexec": "FM_X_ARG0_NOCLOEXEC", "map": "FM_X_MAP", diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index b3056ac2..38321991 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -26,6 +26,9 @@ # fd:unlinked an O_RDWR fd on "file", which is then unlinked # pair an int[2] the call fills with two fds; an fd answer is # then asked of both +# buf a zeroed 4 KiB scratch buffer; for a row with a child +# answer its first int starts at -1, which is neither a +# pid nor an fd # region the address of a fresh anonymous read-write mapping # stackpage a 64 KiB span of the caller's own stack, below every # frame in use @@ -74,6 +77,10 @@ # first write alone, or both # stack:lowreadonly stack:lowwritable # a store into the stack beneath stackpage faults, or works +# child the call returns a pid, and that process exits 0 +# child:ptid child:noptid also, the int at buf holds that pid, or +# still holds -1 +# child:pidfd also, the int at buf is a close-on-exec fd # = (last field only) the same answer as with the flag # # A row that starts with "unsupported:ANSWER" is a flag elfuse declines on @@ -314,3 +321,7 @@ mprotect | 0x10000000@unknown | region 4096 F|PROT_READ | err:EINVAL | region:re unsupported:err:EINVAL mprotect | 0x10@prot-bti | region 4096 F|PROT_READ | region:readonly | = mprotect | 0x20@prot-mte | region 4096 F|PROT_READ | err:EINVAL | region:readonly mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly + +clone | CLONE_VFORK | F|SIGCHLD 0 0 0 0 | child | = +clone | CLONE_CHILD_SETTID | F|SIGCHLD 0 0 0 buf | child:noptid | = +clone | CLONE_CHILD_CLEARTID | F|SIGCHLD 0 0 0 buf | child:noptid | = diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index c5f99358..bd7ceb9d 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -36,6 +37,7 @@ #include #include #include +#include #include #include @@ -117,6 +119,10 @@ enum fm_expect_kind { FM_X_REGION_WRITABLE, FM_X_REGION_READONLY, FM_X_REGION_NOREAD, + FM_X_CHILD, + FM_X_CHILD_PTID, + FM_X_CHILD_NOPTID, + FM_X_CHILD_PIDFD, FM_X_PAIR_PACKETS, FM_X_PAIR_STREAM, FM_X_STACK_LOW_READONLY, @@ -143,12 +149,17 @@ struct fm_row { struct fm_expect elfuse_with; }; +/* Newer than some of the toolchains this builds with. */ #ifndef MFD_NOEXEC_SEAL #define MFD_NOEXEC_SEAL 0x0008U #endif #ifndef __NR_fchmodat2 #define __NR_fchmodat2 452 #endif +#ifndef CLONE_PIDFD +#define CLONE_PIDFD 0x00001000 +#endif + #include "flag-matrix-vectors.h" static const char *const fm_paths[] = { @@ -578,6 +589,31 @@ static int answer_matches(const struct fm_expect *x, long rc) x->kind == FM_X_STACK_LOW_WRITABLE ? "not " : ""), 0; return 1; + case FM_X_CHILD: + case FM_X_CHILD_PTID: + case FM_X_CHILD_NOPTID: + case FM_X_CHILD_PIDFD: { + int status = 0, slot; + if (rc <= 0) + return WHY("rc=%ld, want a child pid", rc), 0; + if (waitpid((pid_t) rc, &status, __WALL) != (pid_t) rc) + return WHY("waitpid failed, errno=%d", errno), 0; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + return WHY("child status %#x", status), 0; + memcpy(&slot, fm_buf, sizeof(slot)); + if (x->kind == FM_X_CHILD_PTID && slot != (int) rc) + return WHY("the tid slot holds %d, child is %ld", slot, rc), 0; + if (x->kind == FM_X_CHILD_NOPTID && slot != -1) + return WHY("the tid slot holds %d, want it untouched", slot), 0; + if (x->kind == FM_X_CHILD_PIDFD) { + fl = slot >= 0 ? fcntl(slot, F_GETFD) : -1; + if (fl >= 0) + close(slot); + if (fl < 0 || !(fl & FD_CLOEXEC)) + return WHY("pidfd slot holds %d, F_GETFD=%d", slot, fl), 0; + } + return 1; + } case FM_X_STATX_REG: case FM_X_STATX_LNK: { struct statx sx; @@ -630,6 +666,13 @@ static int run_one(const struct fm_row *row, int with, int strict) exit(2); } memset(fm_buf, 0, sizeof(fm_buf)); + + /* A clone row reads a tid or a pidfd back out of the buffer. 0 is a + * descriptor a pidfd can be given, so the slot starts at -1, which is + * neither. + */ + if (x->kind >= FM_X_CHILD && x->kind <= FM_X_CHILD_PIDFD) + memset(fm_buf, 0xff, sizeof(int)); stack_prepare(); fm_region = mmap(NULL, FM_REGION_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); @@ -680,8 +723,15 @@ static int run_one(const struct fm_row *row, int with, int strict) } fm_arg0 = a[0]; + long self = raw_syscall0(__NR_getpid); long rc = raw_syscall6(row->nr, a[0], a[1], a[2], a[3], a[4], a[5]); + /* A row that made a process leaves the new one here as well. It has nothing + * to report; the parent reads the answer. + */ + if (raw_syscall0(__NR_getpid) != self) + _exit(0); + /* A call that fills an fd pair answers through both of them, unless the * answer names the second one. */ @@ -712,6 +762,11 @@ static int run_one(const struct fm_row *row, int with, int strict) if (x->kind >= FM_X_MAP && x->kind <= FM_X_MAP_KEEPS_FILE && is_mapping(rc)) munmap((void *) rc, 4096); + /* A child the row made without expecting one is not left behind. */ + if (rc > 0 && !(x->kind >= FM_X_CHILD && x->kind <= FM_X_CHILD_PIDFD) && + row->nargs == 5 && !returns_fd(x)) + waitpid((pid_t) rc, NULL, __WALL | WNOHANG); + /* Give the stack back whatever the row did to it, with the grow bit so that * everything beneath the span comes back too, and without it in case the * bit was refused. From 2e728fc862f183751b3bed6a4a1a23b1a9ccfe3b Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:25:01 +0800 Subject: [PATCH 23/34] Refuse clone flags that need a missing partner 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. --- src/runtime/forkipc.c | 11 +++++++++++ tests/flag-matrix.tbl | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index c9eb8ec8..12c61cc7 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -83,6 +83,7 @@ * sys_clone further down. */ #define LINUX_CLONE_VM 0x00000100 +#define LINUX_CLONE_SIGHAND 0x00000800 #define LINUX_CLONE_VFORK 0x00004000 #define LINUX_CLONE_THREAD 0x00010000 #define LINUX_CLONE_SETTLS 0x00080000 @@ -1528,6 +1529,16 @@ int64_t sys_clone(hv_vcpu_t vcpu, if ((flags & ~(uint64_t) 0xff) & LINUX_CLONE3_NS_FLAGS) return -LINUX_EINVAL; + /* A thread shares its signal handlers, and shared handlers need shared + * memory. Linux refuses either flag without the one it depends on, and + * every libc sets all three for a thread. Taken at its word, CLONE_THREAD + * alone started a thread here, on whatever stack it came with. + */ + if ((flags & LINUX_CLONE_THREAD) && !(flags & LINUX_CLONE_SIGHAND)) + return -LINUX_EINVAL; + if ((flags & LINUX_CLONE_SIGHAND) && !(flags & LINUX_CLONE_VM)) + return -LINUX_EINVAL; + /* CLONE_THREAD: create a new thread in the same VM (not a new process) */ if (flags & LINUX_CLONE_THREAD) { return sys_clone_thread(vcpu, g, flags, child_stack, stack_map_start, diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 38321991..7b47ab3c 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -325,3 +325,6 @@ mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly clone | CLONE_VFORK | F|SIGCHLD 0 0 0 0 | child | = clone | CLONE_CHILD_SETTID | F|SIGCHLD 0 0 0 buf | child:noptid | = clone | CLONE_CHILD_CLEARTID | F|SIGCHLD 0 0 0 buf | child:noptid | = +# A thread shares signal handlers, and shared handlers need shared memory. +clone | CLONE_THREAD@no-sighand | F|SIGCHLD 0 0 0 0 | err:EINVAL | child +clone | CLONE_SIGHAND@no-vm | F|SIGCHLD 0 0 0 0 | err:EINVAL | child From 8004e78f1443bf55eddebe80c73be60c871e8433 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:34:29 +0800 Subject: [PATCH 24/34] Mark every pidfd close-on-exec 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. --- src/syscall/proc-pidfd.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/syscall/proc-pidfd.c b/src/syscall/proc-pidfd.c index ad24c839..4170a7d1 100644 --- a/src/syscall/proc-pidfd.c +++ b/src/syscall/proc-pidfd.c @@ -166,6 +166,11 @@ int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) return -LINUX_EMFILE; } + /* Linux makes every pidfd close-on-exec, from pidfd_open and from + * CLONE_PIDFD alike, and has no flag to ask for anything else. + */ + fd_publish_linux_flags(gfd, LINUX_O_CLOEXEC); + pthread_mutex_lock(&pidfd_lock); pidfd_entry_t *entry = pidfd_find_free_entry(); if (!entry) { From 5fe4e1b20e33f71e137246e6b7faa769e73dea27 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:34:30 +0800 Subject: [PATCH 25/34] Honor CLONE_PIDFD and the tid slot in a fork 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. --- scripts/check-eintr-contract.py | 2 +- src/runtime/forkipc.c | 223 ++++++++++++++++++++++++-------- src/syscall/fdtable.c | 12 ++ src/syscall/internal.h | 5 + src/syscall/proc-pidfd.c | 62 ++++++++- src/syscall/proc-pidfd.h | 14 ++ tests/flag-matrix.tbl | 9 ++ tests/test-clone3.c | 28 ++++ 8 files changed, 299 insertions(+), 56 deletions(-) diff --git a/scripts/check-eintr-contract.py b/scripts/check-eintr-contract.py index 6e5de8ec..6cb03fdd 100644 --- a/scripts/check-eintr-contract.py +++ b/scripts/check-eintr-contract.py @@ -352,7 +352,7 @@ "not-a-wait", "Refuses the overlay because an execve is reaping this thread.", ), - "runtime/forkipc.c::sys_clone": ( + "runtime/forkipc.c::clone_dispatch": ( "not-a-wait", "Refuses the fork because an execve is reaping this thread.", ), diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index 12c61cc7..8248f292 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -84,6 +84,7 @@ */ #define LINUX_CLONE_VM 0x00000100 #define LINUX_CLONE_SIGHAND 0x00000800 +#define LINUX_CLONE_PIDFD 0x00001000 #define LINUX_CLONE_VFORK 0x00004000 #define LINUX_CLONE_THREAD 0x00010000 #define LINUX_CLONE_SETTLS 0x00080000 @@ -1503,13 +1504,49 @@ static int fork_snapshot_shm_via_clonefile(int src_fd) return clone_fd; } -/* Over the function-size limit on purpose. +static void clone_pidfd_discard(int pfd) +{ + fd_entry_t snap; + if (fd_snapshot_and_close(pfd, &snap)) + fd_cleanup_entry(pfd, &snap); +} + +/* CLONE_PIDFD: store the guest fd number of a pidfd just made at gva, which is + * parent_tid for clone and a field of its own for clone3. pfd is what the call + * that made the pidfd returned. * - * One transaction. The failure paths unwind state established earlier in the - * same function (quiesced siblings, promoted overlays, the snapshot fd), so the - * goto ladder has to see all of it. + * Returns the guest fd, or a negative Linux errno with nothing left behind. + * + * Linux makes and stores the pidfd inside copy_process, before the child can + * run, and fails the clone when the descriptor table is full (EMFILE) or the + * slot cannot be written (EFAULT). The callers run this where the child can + * still be called off for the same reason. */ -/* NOLINTNEXTLINE(readability-function-size) */ +static int64_t clone_pidfd_store(guest_t *g, int pfd, uint64_t gva) +{ + if (pfd < 0) + return pfd; + + int32_t pfd32 = (int32_t) pfd; + if (guest_write_small(g, gva, &pfd32, sizeof(pfd32)) < 0) { + clone_pidfd_discard(pfd); + return -LINUX_EFAULT; + } + return pfd; +} + +static int64_t clone_dispatch(hv_vcpu_t vcpu, + guest_t *g, + uint64_t flags, + uint64_t child_stack, + uint64_t stack_map_start, + uint64_t stack_map_end, + uint64_t ptid_gva, + uint64_t tls, + uint64_t ctid_gva, + const uint64_t *pidfd_gva, + bool verbose); + int64_t sys_clone(hv_vcpu_t vcpu, guest_t *g, uint64_t flags, @@ -1520,6 +1557,39 @@ int64_t sys_clone(hv_vcpu_t vcpu, uint64_t tls, uint64_t ctid_gva, bool verbose) +{ + /* clone(2) has no argument for the pidfd and returns it through parent_tid, + * which is why Linux refuses CLONE_PIDFD beside CLONE_PARENT_SETTID there. + * clone3 has a field of its own for it and calls clone_dispatch itself. + */ + bool want_pidfd = (flags & LINUX_CLONE_PIDFD) != 0; + if (want_pidfd && (flags & LINUX_CLONE_PARENT_SETTID)) + return -LINUX_EINVAL; + + return clone_dispatch(vcpu, g, flags & ~(uint64_t) LINUX_CLONE_PIDFD, + child_stack, stack_map_start, stack_map_end, ptid_gva, + tls, ctid_gva, want_pidfd ? &ptid_gva : NULL, + verbose); +} + +/* Over the function-size limit on purpose. + * + * One transaction. The failure paths unwind state established earlier in the + * same function (quiesced siblings, promoted overlays, the snapshot fd), so the + * goto ladder has to see all of it. + */ +/* NOLINTNEXTLINE(readability-function-size) */ +static int64_t clone_dispatch(hv_vcpu_t vcpu, + guest_t *g, + uint64_t flags, + uint64_t child_stack, + uint64_t stack_map_start, + uint64_t stack_map_end, + uint64_t ptid_gva, + uint64_t tls, + uint64_t ctid_gva, + const uint64_t *pidfd_gva, + bool verbose) { /* Namespaces are not implemented. CLONE_NEWTIME (0x80) lives in the CSIGNAL * low byte and, like CLONE_INTO_CGROUP (bit 33) and set_tid, cannot be @@ -1539,11 +1609,45 @@ int64_t sys_clone(hv_vcpu_t vcpu, if ((flags & LINUX_CLONE_SIGHAND) && !(flags & LINUX_CLONE_VM)) return -LINUX_EINVAL; - /* CLONE_THREAD: create a new thread in the same VM (not a new process) */ - if (flags & LINUX_CLONE_THREAD) { - return sys_clone_thread(vcpu, g, flags, child_stack, stack_map_start, - stack_map_end, ptid_gva, tls, ctid_gva, - verbose); + /* A thread and a CLONE_VM child both live in this process and share its + * descriptor table, so the pidfd is made and stored before the task exists, + * where a failure costs nothing, and pointed at the task once its tid is + * known. Neither has a host pid, so the pidfd carries no monitor. + */ + bool is_thread = (flags & LINUX_CLONE_THREAD) != 0; + bool is_vfork = (flags & LINUX_CLONE_VFORK) != 0; + if (is_thread || ((flags & LINUX_CLONE_VM) && !is_vfork)) { + int64_t pfd = -1; + uint64_t pfd_gen = 0; + if (pidfd_gva) { + pfd = clone_pidfd_store(g, pidfd_create_unbound(g, &pfd_gen), + *pidfd_gva); + if (pfd < 0) + return pfd; + } + + /* CLONE_THREAD: a new thread in the same VM (not a new process). + * + * CLONE_VM without it: an in-process VM-clone child that shares guest + * memory and is waitable via wait4/ptrace. CLONE_VFORK is the exception + * and goes through the helper-process path below, so the child's later + * execve replaces only the child image rather than resetting the + * parent's shared guest_t. + */ + int64_t ret = + is_thread + ? sys_clone_thread(vcpu, g, flags, child_stack, stack_map_start, + stack_map_end, ptid_gva, tls, ctid_gva, + verbose) + : sys_clone_vm(vcpu, g, flags, child_stack, stack_map_start, + stack_map_end, ptid_gva, tls, ctid_gva, verbose); + if (pfd >= 0) { + if (ret > 0) + pidfd_set_target(pfd_gen, ret); + else + pidfd_discard(pfd_gen); + } + return ret; } /* Rosetta fork takes the helper-process IPC path. The parent cannot remap @@ -1558,23 +1662,22 @@ int64_t sys_clone(hv_vcpu_t vcpu, /* elfuse only supports fork-like clone (SIGCHLD) and posix_spawn-like * clone (CLONE_VM|CLONE_VFORK|SIGCHLD) */ - bool is_vfork = (flags & LINUX_CLONE_VFORK) != 0; - - /* CLONE_VM without CLONE_THREAD usually creates an in-process VM-clone - * child that shares guest memory and is waitable via wait4/ptrace. However - * CLONE_VFORK must go through the helper-process path below so the child's - * later execve replaces only the child image rather than resetting the - * parent's shared guest_t. - */ - if ((flags & LINUX_CLONE_VM) && !(flags & LINUX_CLONE_THREAD) && - !is_vfork) { - return sys_clone_vm(vcpu, g, flags, child_stack, stack_map_start, - stack_map_end, ptid_gva, tls, ctid_gva, verbose); - } log_debug("clone(flags=0x%llx, vfork=%d)", (unsigned long long) flags, is_vfork); + /* The pidfd slot is stored once the child exists, and a failure there + * unwinds a helper process that was spawned for nothing. A slot that cannot + * be written now gets its EFAULT before any of that, by storing back the + * four bytes it holds. + */ + if (pidfd_gva) { + int32_t held; + if (guest_read_small(g, *pidfd_gva, &held, sizeof(held)) < 0 || + guest_write_small(g, *pidfd_gva, &held, sizeof(held)) < 0) + return -LINUX_EFAULT; + } + /* socketpair provides the control channel used to transfer snapshot state * and SCM_RIGHTS file descriptors to the fork-child process. */ @@ -1749,6 +1852,7 @@ int64_t sys_clone(hv_vcpu_t vcpu, int snapshot_shm_fd = -1; bool siblings_quiesced = false; int64_t fail_rc = -LINUX_ENOMEM; + int child_pidfd = -1; /* Quiesce sibling vCPUs for snapshot consistency. In multithreaded guests, * sibling vCPUs may be actively mutating guest memory during the fork @@ -2014,6 +2118,21 @@ int64_t sys_clone(hv_vcpu_t vcpu, log_error("clone: failed to commit child bookkeeping"); goto fail_snapshot; } + + /* Linux makes the pidfd after it has copied the descriptor table and before + * the child can run. Here that is after the fd table went to the child, + * which must not inherit the descriptor, and before the child is admitted, + * so a failure unwinds a child that never ran. + */ + if (pidfd_gva) { + int64_t prc = clone_pidfd_store( + g, pidfd_create(g, child_guest_pid, child_host_pid), *pidfd_gva); + if (prc < 0) { + fail_rc = prc; + goto fail_snapshot; + } + child_pidfd = (int) prc; + } uint8_t admission_ready = 1; if (fork_ipc_write_all(ipc_sock, &admission_ready, sizeof(admission_ready)) < 0) { @@ -2080,12 +2199,24 @@ int64_t sys_clone(hv_vcpu_t vcpu, log_debug("clone: child pid=%lld (host=%d)", (long long) child_guest_pid, child_host_pid); + /* The thread and CLONE_VM paths store the child's tid for + * CLONE_PARENT_SETTID themselves. A fork has to as well: Linux writes it + * into the parent's memory once the child exists, and ignores a slot it + * cannot write. + */ + if (flags & LINUX_CLONE_PARENT_SETTID) { + int32_t tid32 = (int32_t) child_guest_pid; + (void) guest_write_small(g, ptid_gva, &tid32, sizeof(tid32)); + } + free(regions_snapshot); if (snapshot_shm_fd >= 0) close(snapshot_shm_fd); return child_guest_pid; fail_snapshot: + if (child_pidfd >= 0) + clone_pidfd_discard(child_pidfd); proc_cancel_child(child_guest_pid); free(regions_snapshot); if (snapshot_shm_fd >= 0) @@ -2145,7 +2276,6 @@ struct linux_clone_args { #define CLONE_ARGS_SIZE_VER0 64 /* v5.3: first 8 fields (flags..tls) */ /* Unsupported clone3-only flags: reject early rather than silently ignoring. */ -#define LINUX_CLONE_PIDFD 0x00001000 #define LINUX_CLONE_INTO_CGROUP 0x200000000ULL int64_t sys_clone3(hv_vcpu_t vcpu, @@ -2213,11 +2343,19 @@ int64_t sys_clone3(hv_vcpu_t vcpu, if ((ca.stack == 0) != (ca.stack_size == 0)) return -LINUX_EINVAL; - /* Merge exit_signal into flags for sys_clone compatibility. clone3 moved - * exit_signal out of the flags field; sys_clone expects it in the low byte. - * Safe because validation confirmed ca.flags low byte is zero. Strip - * CLONE_PIDFD before passing to sys_clone (which does not understand it). - * Pidfd creation happens after the clone returns. + /* clone3 gives the pidfd a field of its own, and Linux still refuses it + * pointing at the same word as parent_tid: one store would overwrite the + * other. + */ + if ((ca.flags & LINUX_CLONE_PIDFD) && + (ca.flags & LINUX_CLONE_PARENT_SETTID) && ca.pidfd == ca.parent_tid) + return -LINUX_EINVAL; + + /* Merge exit_signal into flags for clone_dispatch. clone3 moved exit_signal + * out of the flags field; clone_dispatch expects it in the low byte. Safe + * because validation confirmed ca.flags low byte is zero. CLONE_PIDFD is + * not passed as a flag: clone_dispatch is handed the address of the pidfd + * field, and makes the pidfd while the child can still be called off. */ bool want_pidfd = (ca.flags & LINUX_CLONE_PIDFD) != 0; uint64_t flags = @@ -2242,28 +2380,7 @@ int64_t sys_clone3(hv_vcpu_t vcpu, (unsigned long long) ca.stack, (unsigned long long) ca.stack_size, (unsigned long long) ca.tls, (unsigned long long) cl_args_size); - int64_t ret = sys_clone(vcpu, g, flags, child_stack, ca.stack, - ca.stack + ca.stack_size, ca.parent_tid, ca.tls, - ca.child_tid, verbose); - - /* If clone succeeded and CLONE_PIDFD was requested, create a pidfd for the - * child and write the guest FD number to ca.pidfd. - */ - if (ret > 0 && want_pidfd && ca.pidfd != 0) { - /* A CLONE_VM child has no host pid of its own, so it resolves to -1 and - * its pidfd carries no monitor. - */ - int pfd = pidfd_create(g, ret, proc_resolve_guest_pid(ret)); - if (pfd >= 0) { - int32_t pfd32 = (int32_t) pfd; - if (guest_write_small(g, ca.pidfd, &pfd32, sizeof(pfd32)) < 0) { - /* GVA invalid; close the newly created pidfd. */ - fd_entry_t snap; - if (fd_snapshot_and_close(pfd, &snap)) - fd_cleanup_entry(pfd, &snap); - } - } - } - - return ret; + return clone_dispatch(vcpu, g, flags, child_stack, ca.stack, + ca.stack + ca.stack_size, ca.parent_tid, ca.tls, + ca.child_tid, want_pidfd ? &ca.pidfd : NULL, verbose); } diff --git a/src/syscall/fdtable.c b/src/syscall/fdtable.c index 0d69bcd5..58c79e93 100644 --- a/src/syscall/fdtable.c +++ b/src/syscall/fdtable.c @@ -932,6 +932,18 @@ bool fd_snapshot_and_close(int fd, fd_entry_t *out) return ok; } +bool fd_snapshot_and_close_gen(int fd, uint64_t generation, fd_entry_t *out) +{ + if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) + return false; + pthread_mutex_lock(&fd_lock); + bool ok = fd_table[fd].type != FD_CLOSED && + fd_table[fd].generation == generation && + fd_snapshot_locked(fd, out, true); + pthread_mutex_unlock(&fd_lock); + return ok; +} + bool fd_snapshot_and_close_relaxed(int fd, fd_entry_t *out) { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) diff --git a/src/syscall/internal.h b/src/syscall/internal.h index 0de01ebd..088c60dd 100644 --- a/src/syscall/internal.h +++ b/src/syscall/internal.h @@ -803,6 +803,11 @@ void fd_retire_published(int fd, int host_fd); */ bool fd_snapshot_and_close(int fd, fd_entry_t *out); +/* The same, for the one allocation that stamped generation on the slot. A slot + * a sibling has closed and reused holds another generation and is left alone. + */ +bool fd_snapshot_and_close_gen(int fd, uint64_t generation, fd_entry_t *out); + /* Snapshot and close with a single-thread fast path. Uses the unlocked table * update when exactly one guest thread is active, otherwise falls back to * fd_snapshot_and_close(). diff --git a/src/syscall/proc-pidfd.c b/src/syscall/proc-pidfd.c index 4170a7d1..1cd63a85 100644 --- a/src/syscall/proc-pidfd.c +++ b/src/syscall/proc-pidfd.c @@ -28,7 +28,8 @@ typedef struct { int guest_fd; int64_t guest_pid; int write_end; - uint64_t gen; /* names this open across guest fd number reuse */ + uint64_t gen; /* names this open across guest fd number reuse */ + uint64_t fd_gen; /* the fd table's generation for guest_fd */ } pidfd_entry_t; static pidfd_entry_t pidfd_table[PIDFD_TABLE_SIZE]; @@ -147,7 +148,25 @@ static void *pidfd_monitor_thread(void *arg) return NULL; } +static int pidfd_create_entry(guest_t *g, + int64_t target_pid, + pid_t host_pid, + uint64_t *gen_out); + int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) +{ + return pidfd_create_entry(g, target_pid, host_pid, NULL); +} + +int pidfd_create_unbound(guest_t *g, uint64_t *gen) +{ + return pidfd_create_entry(g, 0, -1, gen); +} + +static int pidfd_create_entry(guest_t *g, + int64_t target_pid, + pid_t host_pid, + uint64_t *gen_out) { (void) g; int pfd[2]; @@ -159,7 +178,8 @@ int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) return linux_errno(); } - int gfd = fd_alloc(FD_PIDFD, pfd[0], pidfd_cleanup); + uint64_t fd_gen = 0; + int gfd = fd_alloc_from(0, FD_PIDFD, pfd[0], pidfd_cleanup, &fd_gen); if (gfd < 0) { close(pfd[0]); close(pfd[1]); @@ -187,8 +207,11 @@ int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) entry->guest_pid = target_pid; entry->write_end = pfd[1]; entry->gen = ++pidfd_next_gen; + entry->fd_gen = fd_gen; uint64_t gen = entry->gen; pthread_mutex_unlock(&pidfd_lock); + if (gen_out) + *gen_out = gen; /* host_pid <= 0 means the target lives inside this host process -- the * caller itself, or a CLONE_VM child, which holds a guest tid but no host @@ -235,6 +258,41 @@ int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) return gfd; } +void pidfd_set_target(uint64_t gen, int64_t target_pid) +{ + pthread_mutex_lock(&pidfd_lock); + for (int i = 0; i < PIDFD_TABLE_SIZE; i++) { + if (pidfd_table[i].active && pidfd_table[i].gen == gen) { + pidfd_table[i].guest_pid = target_pid; + break; + } + } + pthread_mutex_unlock(&pidfd_lock); +} + +void pidfd_discard(uint64_t gen) +{ + int gfd = -1; + uint64_t fd_gen = 0; + pthread_mutex_lock(&pidfd_lock); + for (int i = 0; i < PIDFD_TABLE_SIZE; i++) { + if (pidfd_table[i].active && pidfd_table[i].gen == gen) { + gfd = pidfd_table[i].guest_fd; + fd_gen = pidfd_table[i].fd_gen; + break; + } + } + pthread_mutex_unlock(&pidfd_lock); + + /* The close hook takes pidfd_lock, so the slot is closed after it is + * released. The fd generation is what keeps a number the guest closed and + * reused in between out of it. + */ + fd_entry_t snap; + if (gfd >= 0 && fd_snapshot_and_close_gen(gfd, fd_gen, &snap)) + fd_cleanup_entry(gfd, &snap); +} + void proc_pidfd_notify_exit(int64_t exited_pid) { pthread_mutex_lock(&pidfd_lock); diff --git a/src/syscall/proc-pidfd.h b/src/syscall/proc-pidfd.h index d1315a3a..166f65a8 100644 --- a/src/syscall/proc-pidfd.h +++ b/src/syscall/proc-pidfd.h @@ -18,6 +18,20 @@ void pidfd_init(void); * watched (the caller itself, or a CLONE_VM child). */ int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid); + +/* Create a pidfd that stands for no task yet, and point it at one later. clone + * makes the descriptor for a thread or a CLONE_VM child before that task exists + * and learns the tid only once the task is made. + * + * *gen names the new entry for pidfd_set_target. The guest fd number cannot: + * the slot is visible to the guest from the moment it is made, and a close + * followed by another pidfd would hand the same number to a different entry. + */ +int pidfd_create_unbound(guest_t *g, uint64_t *gen); +void pidfd_set_target(uint64_t gen, int64_t target_pid); + +/* Close the pidfd *gen names, if the guest has not closed it already. */ +void pidfd_discard(uint64_t gen); void proc_pidfd_notify_exit(int64_t exited_pid); int64_t proc_pidfd_lookup_pid(int guest_fd); int64_t sys_pidfd_open(guest_t *g, int64_t pid, unsigned int flags); diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index 7b47ab3c..ae3b316b 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -322,6 +322,15 @@ unsupported:err:EINVAL mprotect | 0x10@prot-bti | region 4096 F|PROT_READ | regi mprotect | 0x20@prot-mte | region 4096 F|PROT_READ | err:EINVAL | region:readonly mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly +# clone(flags, stack, parent_tid, tls, child_tid), the arm64 argument order. +# A zero stack with no CLONE_VM is a fork: the child gets a copy and exits. +clone | CLONE_PARENT_SETTID | F|SIGCHLD 0 buf 0 0 | child:ptid | child:noptid +clone | CLONE_PIDFD | F|SIGCHLD 0 buf 0 0 | child:pidfd | child:noptid +# clone returns the pidfd through parent_tid, so the two cannot be combined. +clone | CLONE_PIDFD@with-parent-settid | F|SIGCHLD|CLONE_PARENT_SETTID 0 buf 0 0 | err:EINVAL | child:ptid +# The pidfd is stored before the child may run: a slot that cannot be written +# fails the clone and leaves no child. +clone | CLONE_PIDFD@bad-slot | F|SIGCHLD 0 8 0 0 | err:EFAULT | child clone | CLONE_VFORK | F|SIGCHLD 0 0 0 0 | child | = clone | CLONE_CHILD_SETTID | F|SIGCHLD 0 0 0 buf | child:noptid | = clone | CLONE_CHILD_CLEARTID | F|SIGCHLD 0 0 0 buf | child:noptid | = diff --git a/tests/test-clone3.c b/tests/test-clone3.c index 1d298dba..1814b28c 100644 --- a/tests/test-clone3.c +++ b/tests/test-clone3.c @@ -257,6 +257,33 @@ static void test_unsupported_flags(void) ret); } +/* CLONE_PIDFD and CLONE_PARENT_SETTID may both be asked for, but not into the + * same word: Linux refuses that before any child exists, and the slot keeps + * what it held. + */ +static void test_pidfd_parent_tid_alias(void) +{ + struct clone_args ca; + memset(&ca, 0, sizeof(ca)); + ca.exit_signal = 17; + + int32_t slot = 0x5a5a5a5a; + ca.flags = 0x00001000 | 0x00100000; /* CLONE_PIDFD | CLONE_PARENT_SETTID */ + ca.pidfd = (uint64_t) &slot; + ca.parent_tid = (uint64_t) &slot; + long ret = raw_clone3(&ca, CLONE_ARGS_SIZE_VER0); + if (ret == 0) { + raw_syscall1(__NR_exit, 0); + __builtin_unreachable(); + } + if (ret > 0) + raw_syscall4(__NR_wait4, ret, 0, 0, 0); + CHECK(ret == -22, + "clone3 pidfd == parent_tid returned %ld (expected -EINVAL)", ret); + CHECK(slot == 0x5a5a5a5a, "clone3 pidfd == parent_tid wrote the slot: %d", + slot); +} + /* Test 5: clone3 with larger struct (zero tail = ok) */ static void test_larger_struct(void) { @@ -720,6 +747,7 @@ int main(int argc, char **argv) test_thread(); test_invalid_size(); test_unsupported_flags(); + test_pidfd_parent_tid_alias(); test_larger_struct(); test_nonzero_tail(); test_csignal_in_flags(); From b4696217b9b365b0656454cc32eeb09575ca2dfc Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 05:11:24 +0800 Subject: [PATCH 26/34] Let a CLONE_VM child see its tid in the 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. --- scripts/gen-flag-matrix.py | 1 + src/runtime/forkipc.c | 22 +++++++++++++++++++++- tests/flag-matrix.tbl | 4 ++++ tests/test-flag-matrix.c | 19 ++++++++++++++----- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py index fa5acd24..6d7723e8 100644 --- a/scripts/gen-flag-matrix.py +++ b/scripts/gen-flag-matrix.py @@ -68,6 +68,7 @@ "child": "FM_X_CHILD", "child:ptid": "FM_X_CHILD_PTID", "child:noptid": "FM_X_CHILD_NOPTID", + "child:ownptid": "FM_X_CHILD_OWNPTID", "child:pidfd": "FM_X_CHILD_PIDFD", "arg0:cloexec": "FM_X_ARG0_CLOEXEC", "arg0:nocloexec": "FM_X_ARG0_NOCLOEXEC", diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index 8248f292..77f02c62 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -1853,6 +1853,8 @@ static int64_t clone_dispatch(hv_vcpu_t vcpu, bool siblings_quiesced = false; int64_t fail_rc = -LINUX_ENOMEM; int child_pidfd = -1; + bool ptid_stored_early = false; + int32_t ptid_before = 0; /* Quiesce sibling vCPUs for snapshot consistency. In multithreaded guests, * sibling vCPUs may be actively mutating guest memory during the fork @@ -1871,6 +1873,20 @@ static int64_t clone_dispatch(hv_vcpu_t vcpu, } siblings_quiesced = true; + /* With CLONE_VM the child runs on the parent's memory, so on Linux it finds + * its own tid in the CLONE_PARENT_SETTID slot from its first instruction. + * The child here is a helper process that runs on a snapshot, so the tid + * has to be in the snapshot: it is stored before one is taken, and what the + * slot held is put back if the clone fails. A slot that cannot be written + * is passed over, as Linux passes over it. + */ + if ((flags & LINUX_CLONE_PARENT_SETTID) && (flags & LINUX_CLONE_VM) && + guest_read_small(g, ptid_gva, &ptid_before, sizeof(ptid_before)) == 0) { + int32_t tid32 = (int32_t) child_guest_pid; + ptid_stored_early = + guest_write_small(g, ptid_gva, &tid32, sizeof(tid32)) == 0; + } + /* Convert MAP_SHARED|MAP_ANONYMOUS regions that have no backing fd into * memfd-backed overlay regions. The conversion seeds a private temp file * with the current bytes and installs a host MAP_SHARED|MAP_FIXED overlay @@ -2202,7 +2218,8 @@ static int64_t clone_dispatch(hv_vcpu_t vcpu, /* The thread and CLONE_VM paths store the child's tid for * CLONE_PARENT_SETTID themselves. A fork has to as well: Linux writes it * into the parent's memory once the child exists, and ignores a slot it - * cannot write. + * cannot write. A CLONE_VM|CLONE_VFORK child had it stored before the + * snapshot too, so that the child sees it. */ if (flags & LINUX_CLONE_PARENT_SETTID) { int32_t tid32 = (int32_t) child_guest_pid; @@ -2217,6 +2234,9 @@ static int64_t clone_dispatch(hv_vcpu_t vcpu, fail_snapshot: if (child_pidfd >= 0) clone_pidfd_discard(child_pidfd); + if (ptid_stored_early) + (void) guest_write_small(g, ptid_gva, &ptid_before, + sizeof(ptid_before)); proc_cancel_child(child_guest_pid); free(regions_snapshot); if (snapshot_shm_fd >= 0) diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index ae3b316b..c83ae935 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -80,6 +80,8 @@ # child the call returns a pid, and that process exits 0 # child:ptid child:noptid also, the int at buf holds that pid, or # still holds -1 +# child:ownptid as child:ptid, and the new process found its own tid +# there too # child:pidfd also, the int at buf is a close-on-exec fd # = (last field only) the same answer as with the flag # @@ -325,6 +327,8 @@ mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly # clone(flags, stack, parent_tid, tls, child_tid), the arm64 argument order. # A zero stack with no CLONE_VM is a fork: the child gets a copy and exits. clone | CLONE_PARENT_SETTID | F|SIGCHLD 0 buf 0 0 | child:ptid | child:noptid +# With CLONE_VM the two share memory, so the child sees the slot as well. +clone | CLONE_PARENT_SETTID@vm-child | F|SIGCHLD|CLONE_VM|CLONE_VFORK 0 buf 0 0 | child:ownptid | child clone | CLONE_PIDFD | F|SIGCHLD 0 buf 0 0 | child:pidfd | child:noptid # clone returns the pidfd through parent_tid, so the two cannot be combined. clone | CLONE_PIDFD@with-parent-settid | F|SIGCHLD|CLONE_PARENT_SETTID 0 buf 0 0 | err:EINVAL | child:ptid diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c index bd7ceb9d..f6f241c8 100644 --- a/tests/test-flag-matrix.c +++ b/tests/test-flag-matrix.c @@ -122,6 +122,7 @@ enum fm_expect_kind { FM_X_CHILD, FM_X_CHILD_PTID, FM_X_CHILD_NOPTID, + FM_X_CHILD_OWNPTID, FM_X_CHILD_PIDFD, FM_X_PAIR_PACKETS, FM_X_PAIR_STREAM, @@ -592,6 +593,7 @@ static int answer_matches(const struct fm_expect *x, long rc) case FM_X_CHILD: case FM_X_CHILD_PTID: case FM_X_CHILD_NOPTID: + case FM_X_CHILD_OWNPTID: case FM_X_CHILD_PIDFD: { int status = 0, slot; if (rc <= 0) @@ -601,7 +603,8 @@ static int answer_matches(const struct fm_expect *x, long rc) if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return WHY("child status %#x", status), 0; memcpy(&slot, fm_buf, sizeof(slot)); - if (x->kind == FM_X_CHILD_PTID && slot != (int) rc) + if ((x->kind == FM_X_CHILD_PTID || x->kind == FM_X_CHILD_OWNPTID) && + slot != (int) rc) return WHY("the tid slot holds %d, child is %ld", slot, rc), 0; if (x->kind == FM_X_CHILD_NOPTID && slot != -1) return WHY("the tid slot holds %d, want it untouched", slot), 0; @@ -726,11 +729,17 @@ static int run_one(const struct fm_row *row, int with, int strict) long self = raw_syscall0(__NR_getpid); long rc = raw_syscall6(row->nr, a[0], a[1], a[2], a[3], a[4], a[5]); - /* A row that made a process leaves the new one here as well. It has nothing - * to report; the parent reads the answer. + /* A row that made a process leaves the new one here as well. The parent + * reads the answer, and the one thing the new process reports, through its + * exit status, is whether it found its own tid in the slot when the row + * asks for that. */ - if (raw_syscall0(__NR_getpid) != self) - _exit(0); + if (raw_syscall0(__NR_getpid) != self) { + int seen; + memcpy(&seen, fm_buf, sizeof(seen)); + _exit(x->kind == FM_X_CHILD_OWNPTID && + seen != (int) raw_syscall0(__NR_gettid)); + } /* A call that fills an fd pair answers through both of them, unless the * answer names the second one. From a8c562efe9f7c3f87f348acc33e2bc022452aea2 Mon Sep 17 00:00:00 2001 From: xalestar Date: Sat, 10 Oct 2026 01:41:16 +0800 Subject: [PATCH 27/34] Hold the flag matrix rows in the coverage audit 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. --- docs/testing.md | 2 + scripts/check-syscall-coverage.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 244bf4ac..5d89f74c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -92,6 +92,8 @@ What they do: - `make check`: fast elfuse-internal gate. Runs, in order: - `scripts/check-syscall-coverage.py` so any new `dispatch.tbl` entry without a direct or aliased test reference fails the build + (the same script also requires the syscalls it lists as flag-taking to + keep their rows in `tests/flag-matrix.tbl`) - `scripts/check-eintr-contract.py` so a new interruptible wait fails the build until it states whether it may be restarted (`forbids`, `restartable`, or `not-a-wait`), with the `forbids` claims checked diff --git a/scripts/check-syscall-coverage.py b/scripts/check-syscall-coverage.py index b0d87a21..8b97fc34 100644 --- a/scripts/check-syscall-coverage.py +++ b/scripts/check-syscall-coverage.py @@ -50,6 +50,53 @@ } +FLAG_MATRIX = TESTS / "flag-matrix.tbl" + +# Syscalls whose flags argument must have rows in tests/flag-matrix.tbl. A name +# mentioned in a test says the call was made; a row says each flag was asserted +# set and clear against a real kernel. Dropping the rows for one of these fails +# the audit, and so does listing a syscall the dispatcher no longer serves. +FLAG_MATRIX_REQUIRED: set[str] = { + "accept4", + "clone", + "dup3", + "epoll_create1", + "eventfd2", + "faccessat2", + "fchmodat", + "fchmodat2", + "fchownat", + "fcntl", + "inotify_init1", + "linkat", + "memfd_create", + "mmap", + "mprotect", + "newfstatat", + "openat", + "pipe2", + "renameat2", + "signalfd4", + "socket", + "socketpair", + "statx", + "timerfd_create", + "unlinkat", + "utimensat", +} + +_ROW_PREFIX = re.compile(r"^(?:pending |unsupported:\S+ )?([a-z][a-z0-9_]*) \| ") + + +def load_flag_matrix_names() -> set[str]: + names: set[str] = set() + for line in FLAG_MATRIX.read_text(encoding="utf-8").splitlines(): + match = _ROW_PREFIX.match(line.strip()) + if match: + names.add(match.group(1)) + return names + + def load_dispatch_names() -> list[str]: names: list[str] = [] for line in DISPATCH.read_text(encoding="utf-8").splitlines(): @@ -135,8 +182,28 @@ def main() -> int: continue missing.append(name) + matrix = load_flag_matrix_names() + no_rows = sorted(FLAG_MATRIX_REQUIRED - matrix) + not_dispatched = sorted(FLAG_MATRIX_REQUIRED - set(load_dispatch_names())) + unlisted = sorted(matrix - FLAG_MATRIX_REQUIRED) + stale_indirect = sorted(set(INDIRECT_COVERAGE) - set(used_indirect)) + failed = False + if no_rows or not_dispatched or unlisted: + failed = True + for label, names in ( + ("Syscalls with no row left in tests/flag-matrix.tbl:", no_rows), + ("FLAG_MATRIX_REQUIRED entries absent from dispatch.tbl:", + not_dispatched), + ("Syscalls with rows but missing from FLAG_MATRIX_REQUIRED:", + unlisted), + ): + if names: + print(label, file=sys.stderr) + for name in names: + print(f" - {name}", file=sys.stderr) if missing or stale_indirect: + failed = True if missing: print("Uncovered syscalls in dispatch.tbl:", file=sys.stderr) for name in missing: @@ -149,9 +216,11 @@ def main() -> int: ) for name in stale_indirect: print(f" - {name}", file=sys.stderr) + if failed: return 1 print("syscall coverage audit: PASS") + print(f" flag matrix: {len(matrix)} syscalls have per-flag rows") for name, reason in sorted(used_indirect.items()): print(f" indirect {name}: {reason}") return 0 From ceb41048da9490e6b784bfcff82c3f9922002a8d Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 01:39:16 +0800 Subject: [PATCH 28/34] Answer faccessat2 for a /proc file that is gone 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. --- src/syscall/fs.c | 7 ++++--- tests/test-file-ops.c | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 5927fb8a..45cb35d4 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -3916,14 +3916,15 @@ static int64_t access_empty_path_fd(int dirfd, int mode, int flags) /* A stamped descriptor stands for a synthetic file, whose mode is the * intercept's and not that of the host fd behind it; the path-based branch - * of sys_faccessat judges such a file the same way. + * of sys_faccessat judges such a file the same way. A synthetic file can + * leave its tree while the descriptor stays open, /proc/self/fdinfo/N once + * N is closed, and Linux still answers for the open file; the host fd + * behind it is all that is left to answer for it here. */ if (snap.proc_path[0] != '\0') { struct stat intercepted_st; int intercepted = proc_intercept_stat_at(snap.proc_path, &intercepted_st, false); - if (intercepted == -1) - return linux_errno(); if (intercepted == 0) { if (path_check_intercept_access(&intercepted_st, mode, flags) < 0) return linux_errno(); diff --git a/tests/test-file-ops.c b/tests/test-file-ops.c index b909fa8f..70fd0086 100644 --- a/tests/test-file-ops.c +++ b/tests/test-file-ops.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -220,6 +221,22 @@ int main(void) "descriptor and path answers differ"); } + /* The open file still answers once its name has left /proc. */ + TEST("faccessat2 AT_EMPTY_PATH on O_PATH fdinfo after its fd closes"); + { + char fdinfo[64]; + int n = dup(1); + snprintf(fdinfo, sizeof(fdinfo), "/proc/self/fdinfo/%d", n); + int fd = n < 0 ? -1 : open(fdinfo, O_PATH); + if (n >= 0) + close(n); + long rc = + fd < 0 ? -2 : syscall(SYS_faccessat2, fd, "", R_OK, AT_EMPTY_PATH); + if (fd >= 0) + close(fd); + EXPECT_TRUE(rc == 0, "faccessat2 on the open fdinfo failed"); + } + /* Test hardlink */ TEST("hardlink (link)"); if (link(testfile, hardlink_path) == 0) { From 476d4e42cc7017fc2f4fbfe320115bb101aba6d2 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 01:39:16 +0800 Subject: [PATCH 29/34] Expect success or ENOMEM from a hugetlb memfd map 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. --- tests/test-fd-lifecycle.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test-fd-lifecycle.c b/tests/test-fd-lifecycle.c index 7fbe172c..9a42c62b 100644 --- a/tests/test-fd-lifecycle.c +++ b/tests/test-fd-lifecycle.c @@ -72,8 +72,8 @@ static void test_memfd_accepts_valid_linux_flags(void) } /* An MFD_HUGETLB memfd is a hugetlbfs file, the one kind of file Linux lets - * MAP_HUGETLB name. Without a huge page pool the mapping can still fail with - * ENOMEM, so only EINVAL is wrong. + * MAP_HUGETLB name. Without a huge page pool the mapping fails with ENOMEM, so + * that answer and success are the two right ones. */ static void test_memfd_hugetlb_maps_hugetlb(void) { @@ -97,8 +97,8 @@ static void test_memfd_hugetlb_maps_hugetlb(void) if (seals != 0) FAIL("F_GET_SEALS reported bits that are not seals"); - else if (map_errno == EINVAL) - FAIL("mmap refused MAP_HUGETLB with EINVAL"); + else if (map_errno != 0 && map_errno != ENOMEM) + FAIL("mmap refused MAP_HUGETLB on a hugetlb memfd"); else PASS(); } From 2c747bef070b7841a39ad8ba230ca72fbebaaa18 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 01:39:16 +0800 Subject: [PATCH 30/34] Drop the PROT_BTI row no lane CPU agrees on 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. --- tests/flag-matrix.tbl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/flag-matrix.tbl b/tests/flag-matrix.tbl index c83ae935..13bbfd51 100644 --- a/tests/flag-matrix.tbl +++ b/tests/flag-matrix.tbl @@ -317,10 +317,9 @@ mprotect | PROT_GROWSDOWN | region 4096 F|PROT_READ | err:EINVAL | region:readon # On the stack the bit extends the change down to the mapping's low end. mprotect | PROT_GROWSDOWN@stack | stackpage 65536 F|PROT_READ | stack:lowreadonly | stack:lowwritable mprotect | 0x10000000@unknown | region 4096 F|PROT_READ | err:EINVAL | region:readonly -# arm64 admits PROT_BTI and PROT_MTE only on a CPU with the feature. The qemu -# guest is shown BTI where the host has it, and elfuse's guest is shown neither -# (ID_AA64PFR1_EL1 reads 0), so elfuse answers as a kernel on a CPU without BTI. -unsupported:err:EINVAL mprotect | 0x10@prot-bti | region 4096 F|PROT_READ | region:readonly | = +# arm64 admits PROT_MTE only on a CPU with MTE, and neither qemu CPU the lane +# picks nor elfuse's guest has it. PROT_BTI has no row: Linux's answer follows +# whether the qemu CPU has BTI, which differs between the HVF and TCG lanes. mprotect | 0x20@prot-mte | region 4096 F|PROT_READ | err:EINVAL | region:readonly mprotect | PROT_GROWSUP | region 4096 F|PROT_READ | err:EINVAL | region:readonly From d2ea66e3a8bd99132441c4cdbbf7ceb7a329a3c6 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 17:09:49 +0800 Subject: [PATCH 31/34] Fail a fresh mapping whose PTEs cannot be set A non-fixed mmap now gives its range the permission asked for, so a PROT_READ range inside a read-write 2 MiB block splits the block, and the split draws on the page-table pool. The result of that update was ignored, so an exhausted pool left the range writable while the region table and /proc/self/maps reported it read-only. Treat the failure like the page-table extension just before it and answer ENOMEM. --- src/syscall/mem.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/syscall/mem.c b/src/syscall/mem.c index ceb54e05..9a5f6f8a 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -3487,7 +3487,11 @@ int64_t sys_mmap(guest_t *g, */ int range_perms = (prot & LINUX_PROT_WRITE) ? ext_perms : prot_to_perms(prot); - guest_update_perms(g, result_off, result_off + length, range_perms); + if (guest_update_perms(g, result_off, result_off + length, + range_perms) < 0) { + mmap_fresh_dispose(&fresh); + return -LINUX_ENOMEM; + } if (ext_end > g->mmap_end) g->mmap_end = ext_end; } From fe09deeea415c3ee3a66ee05f40f6b12f27ea601 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 17:09:49 +0800 Subject: [PATCH 32/34] Freeze an executable memfd sealed with F_SEAL_EXEC Linux's memfd_add_seals adds F_SEAL_SHRINK, F_SEAL_GROW, F_SEAL_WRITE and F_SEAL_FUTURE_WRITE with F_SEAL_EXEC when the file mode still has an execute bit. A memfd is now created 0777, so MFD_ALLOW_SEALING and then F_SEAL_EXEC left the contents writable and resizable here, while Linux freezes them and F_GET_SEALS reports 0x3e. --- src/syscall/fs.c | 23 ++++++++++++++++ tests/test-fd-lifecycle.c | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 45cb35d4..eba3405c 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -1722,6 +1722,24 @@ static int64_t fcntl_flock_wait(guest_t *g, } } +/* memfd_add_seals: F_SEAL_EXEC on a file that is still executable also freezes + * its contents and size. + * + * Returns 0 or a negative Linux errno. + */ +static int64_t seal_exec_implied(int host_fd, int *seals) +{ + if (!(*seals & LINUX_F_SEAL_EXEC)) + return 0; + struct stat st; + if (fstat(host_fd, &st) < 0) + return linux_errno(); + if (st.st_mode & 0111) + *seals |= LINUX_F_SEAL_SHRINK | LINUX_F_SEAL_GROW | LINUX_F_SEAL_WRITE | + LINUX_F_SEAL_FUTURE_WRITE; + return 0; +} + int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) @@ -2104,6 +2122,11 @@ int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) host_fd_ref_close(&host_ref); return -LINUX_EINVAL; } + int64_t exec_err = seal_exec_implied(host_ref.fd, &new_seals); + if (exec_err < 0) { + host_fd_ref_close(&host_ref); + return exec_err; + } fd_set_seals_for_aliases(fd, host_ref.fd, cur | new_seals); host_fd_ref_close(&host_ref); return 0; diff --git a/tests/test-fd-lifecycle.c b/tests/test-fd-lifecycle.c index 9a42c62b..4b807106 100644 --- a/tests/test-fd-lifecycle.c +++ b/tests/test-fd-lifecycle.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,10 @@ #define F_SEAL_WRITE 0x0008 #endif +#ifndef F_SEAL_EXEC +#define F_SEAL_EXEC 0x0020 +#endif + int passes = 0, fails = 0; static int create_memfd(void) @@ -103,6 +108,55 @@ static void test_memfd_hugetlb_maps_hugetlb(void) PASS(); } +/* F_SEAL_EXEC on a memfd that still has an execute bit also adds + * SHRINK|GROW|WRITE|FUTURE_WRITE (mm/memfd.c, memfd_add_seals). + */ +static void test_memfd_seal_exec_freezes_exec_file(void) +{ + TEST("F_SEAL_EXEC freezes an executable memfd"); + + int fd = create_memfd(); + if (fd < 0) { + FAIL("memfd_create failed"); + return; + } + + char c = 'x'; + int rc = fcntl(fd, F_ADD_SEALS, F_SEAL_EXEC); + int seals = fcntl(fd, F_GET_SEALS); + errno = 0; + ssize_t w = write(fd, &c, 1); + int w_errno = errno; + close(fd); + + if (rc < 0 || seals != 0x3e) + FAIL("seals are not EXEC|FUTURE_WRITE|WRITE|GROW|SHRINK"); + else if (w != -1 || w_errno != EPERM) + FAIL("write to the frozen memfd did not fail with EPERM"); + else + PASS(); +} + +static void test_memfd_seal_exec_alone_without_exec_bits(void) +{ + TEST("F_SEAL_EXEC adds nothing else on a 0666 memfd"); + + int fd = create_memfd(); + if (fd < 0) { + FAIL("memfd_create failed"); + return; + } + + int rc = fchmod(fd, 0666) < 0 ? -1 : fcntl(fd, F_ADD_SEALS, F_SEAL_EXEC); + int seals = fcntl(fd, F_GET_SEALS); + close(fd); + + if (rc < 0 || seals != F_SEAL_EXEC) + FAIL("seals are not F_SEAL_EXEC alone"); + else + PASS(); +} + static void test_memfd_seals_survive_dup(void) { TEST("memfd seals copied to dup"); @@ -345,6 +399,8 @@ int main(void) test_memfd_seals_cleared_on_reuse(); test_memfd_accepts_valid_linux_flags(); test_memfd_hugetlb_maps_hugetlb(); + test_memfd_seal_exec_freezes_exec_file(); + test_memfd_seal_exec_alone_without_exec_bits(); test_rlimit_nofile_reports_emfile(); test_dup3_above_rlimit_fails(); test_nofile_hides_host_reserve(); From 133af71cd68be15fc709b9447feb64ef199d3579 Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 17:09:50 +0800 Subject: [PATCH 33/34] Open a descriptor's directory for search only linkat with AT_EMPTY_PATH and readlinkat with an empty name find the descriptor's entry by opening its parent directory. O_RDONLY needs read permission on that directory, which Linux does not ask for, so a parent with mode 0311 made both calls answer ENOENT for a live file. O_SEARCH needs search permission only and still serves as the dirfd for the fstatat, linkat and readlinkat that follow. --- src/syscall/fs.c | 13 +++++++------ tests/test-file-ops.c | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/syscall/fs.c b/src/syscall/fs.c index eba3405c..7530ef4d 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -3659,10 +3659,11 @@ static int64_t linkat_symlink_fallback(host_fd_t old_dirfd, * would let a directory on the way be exchanged for a symlink, which the host * follows out of the tree the guest is held in, or for another directory, which * makes the call act on a file the descriptor does not name. So the directory - * is opened with O_NOFOLLOW_ANY and held, the entry is compared with the - * descriptor's file, and the caller acts on that one entry without following - * it. What a rename can still do after the comparison is put another entry of - * the same directory under the name. + * is opened with O_NOFOLLOW_ANY and held, for search only because Linux needs + * no read permission on it here, the entry is compared with the descriptor's + * file, and the caller acts on that one entry without following it. What a + * rename can still do after the comparison is put another entry of the same + * directory under the name. */ static int host_fd_live_entry(int host_fd, const struct stat *fd_st, @@ -3679,10 +3680,10 @@ static int host_fd_live_entry(int host_fd, /* The entry's directory is everything before the last slash, or "/". */ int dir_fd; if (slash == buf) { - dir_fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + dir_fd = open("/", O_SEARCH | O_CLOEXEC); } else { *slash = '\0'; - dir_fd = open(buf, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW_ANY); + dir_fd = open(buf, O_SEARCH | O_CLOEXEC | O_NOFOLLOW_ANY); } if (dir_fd < 0) return -1; diff --git a/tests/test-file-ops.c b/tests/test-file-ops.c index 70fd0086..32d8b94d 100644 --- a/tests/test-file-ops.c +++ b/tests/test-file-ops.c @@ -200,6 +200,41 @@ int main(void) close(fd); } + /* Acting on a descriptor needs search permission on its parent, not read + * permission. + */ + TEST("linkat and readlinkat on a descriptor in a 0311 directory"); + { + char dir[256], file[256], link[256], linked[256], buf[256]; + snprintf(dir, sizeof(dir), "%s/noread", tmpdir); + snprintf(file, sizeof(file), "%s/f", dir); + snprintf(link, sizeof(link), "%s/l", dir); + snprintf(linked, sizeof(linked), "%s/linked", tmpdir); + int ffd = -1, lfd = -1; + if (mkdir(dir, 0755) == 0 && create_test_file(file, "x") == 0 && + symlink("f", link) == 0) { + ffd = open(file, O_RDONLY); + lfd = open(link, O_PATH | O_NOFOLLOW); + } + bool ready = ffd >= 0 && lfd >= 0 && chmod(dir, 0311) == 0; + long lrc = ready ? syscall(SYS_linkat, ffd, "", AT_FDCWD, linked, + AT_EMPTY_PATH) + : -2; + long n = + ready ? syscall(SYS_readlinkat, lfd, "", buf, sizeof(buf)) : -2; + chmod(dir, 0755); + EXPECT_TRUE(lrc == 0 && n == 1 && buf[0] == 'f', + "descriptor call failed without read permission"); + if (ffd >= 0) + close(ffd); + if (lfd >= 0) + close(lfd); + unlink(linked); + unlink(link); + unlink(file); + rmdir(dir); + } + /* A descriptor on a synthetic file answers by the file's own mode, as its * path does. Which answer that is depends on who runs the test (root may * write a 0444 file), so the two are compared rather than fixed. From 06b583b3ec59822179d605c5d5015e083370a01a Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 16 Oct 2026 17:42:57 +0800 Subject: [PATCH 34/34] Roll back a fresh mapping whose PTEs cannot be set The ENOMEM return added for a failed guest_update_perms only disposed the fresh state. By then mmap_next has been raised past the range, and guest_update_perms can fail on a block split after it has rewritten earlier blocks, so the range stayed mapped with no region behind it. Undo both with rollback_fresh_mmap_allocation, as the later failure paths of sys_mmap do. --- src/syscall/mem.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 9a5f6f8a..298adcaf 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -3489,6 +3489,11 @@ int64_t sys_mmap(guest_t *g, (prot & LINUX_PROT_WRITE) ? ext_perms : prot_to_perms(prot); if (guest_update_perms(g, result_off, result_off + length, range_perms) < 0) { + /* A split can fail after earlier blocks were rewritten. */ + (void) rollback_fresh_mmap_allocation( + g, result_off, length, false, 0, 0, saved_mmap_next, + saved_mmap_end, saved_mmap_rx_next, saved_mmap_rx_end, + saved_rw_gap_hint, saved_rx_gap_hint); mmap_fresh_dispose(&fresh); return -LINUX_ENOMEM; }