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..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 @@ -116,6 +118,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 +540,29 @@ 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. 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. + `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/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/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 diff --git a/scripts/gen-flag-matrix.py b/scripts/gen-flag-matrix.py new file mode 100644 index 00000000..6d7723e8 --- /dev/null +++ b/scripts/gen-flag-matrix.py @@ -0,0 +1,299 @@ +#!/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", + "sealed": "FM_FD_SEALED", "unlinked": "FM_FD_UNLINKED", + "procpath": "FM_FD_PROCPATH"} + +# 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", + "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", + "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", + "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 = { + "err": "FM_X_ERR", + "errkeeps": "FM_X_ERR_KEEPS", + "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", + "arg0:nogetfl": "FM_X_ARG0_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 == "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:"): + 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/src/runtime/forkipc.c b/src/runtime/forkipc.c index c9eb8ec8..77f02c62 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -83,6 +83,8 @@ * sys_clone further down. */ #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 @@ -1502,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, @@ -1519,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 @@ -1528,11 +1599,55 @@ int64_t sys_clone(hv_vcpu_t vcpu, if ((flags & ~(uint64_t) 0xff) & LINUX_CLONE3_NS_FLAGS) 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 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; + + /* 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 @@ -1547,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. */ @@ -1738,6 +1852,9 @@ 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; + 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 @@ -1756,6 +1873,20 @@ int64_t sys_clone(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 @@ -2003,6 +2134,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) { @@ -2069,12 +2215,28 @@ 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. 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; + (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); + 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) @@ -2134,7 +2296,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, @@ -2202,11 +2363,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 = @@ -2231,28 +2400,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/fs-stat.c b/src/syscall/fs-stat.c index e57fca81..78a24f9d 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) @@ -911,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/src/syscall/fs.c b/src/syscall/fs.c index 5255fdcd..7530ef4d 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -743,6 +744,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 +862,32 @@ 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. + */ + 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; @@ -1687,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)) @@ -2049,7 +2102,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); @@ -2063,12 +2116,17 @@ 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; } + 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; @@ -2910,7 +2968,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 @@ -2955,12 +3020,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 = @@ -2969,7 +3097,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) { @@ -3006,6 +3133,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]; @@ -3035,15 +3201,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) @@ -3114,6 +3294,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) @@ -3423,6 +3609,167 @@ 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; +} + +/* 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, 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, + 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_SEARCH | O_CLOEXEC); + } else { + *slash = '\0'; + dir_fd = open(buf, O_SEARCH | 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, @@ -3436,9 +3783,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 || @@ -3464,49 +3824,160 @@ 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(); - } + 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); + } - 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; + host_fd_ref_close(&olddir_ref); + host_fd_ref_close(&newdir_ref); + return rc; +} + +/* 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); } - target[target_len] = '\0'; + 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; +} - 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(); +/* 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. 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 == 0) { + if (path_check_intercept_access(&intercepted_st, mode, flags) < 0) + return linux_errno(); + return 0; } } - host_fd_ref_close(&olddir_ref); - host_fd_ref_close(&newdir_ref); - 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, @@ -3515,26 +3986,29 @@ int64_t sys_faccessat(guest_t *g, 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]; @@ -3545,9 +4019,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); @@ -3686,22 +4157,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, @@ -3750,24 +4242,28 @@ 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 + * 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(). */ 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; } } @@ -3937,6 +4433,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. */ @@ -4086,11 +4588,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 @@ -4133,7 +4648,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/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/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/linux-wire.h b/src/syscall/linux-wire.h index 7c7e1410..c32f2b11 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 @@ -320,14 +321,35 @@ 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 +#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_HUGETLB 0x40000 #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 | LINUX_MAP_HUGETLB | (0x1f << 26)) + /* Linux msync flags. */ #define LINUX_MS_ASYNC 0x1 #define LINUX_MS_INVALIDATE 0x2 @@ -540,10 +562,25 @@ 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 + * 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 +#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/mem.c b/src/syscall/mem.c index 43dda4f4..298adcaf 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); @@ -2918,13 +2926,45 @@ int64_t sys_mmap(guest_t *g, * (CLONE_VM semantics). */ + /* 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, + * 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; + + /* 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 */ 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 = @@ -3438,7 +3478,25 @@ 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); + 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; + } if (ext_end > g->mmap_end) g->mmap_end = ext_end; } @@ -4591,9 +4649,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); @@ -4601,10 +4661,49 @@ 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; + + /* 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 + * 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/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/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/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/proc-pidfd.c b/src/syscall/proc-pidfd.c index ad24c839..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,13 +178,19 @@ 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]); 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) { @@ -182,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 @@ -230,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/src/syscall/syscall.c b/src/syscall/syscall.c index 572d23a9..93544e4b 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, @@ -1863,11 +1873,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; @@ -1875,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); @@ -1889,8 +1925,17 @@ 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 new file mode 100644 index 00000000..13bbfd51 --- /dev/null +++ b/tests/flag-matrix.tbl @@ -0,0 +1,342 @@ +# 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 +# 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 +# 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 +# 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: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 +# 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 +# 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 +# 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 +# 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 +# +# 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 +# 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 | = + +# 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 + +# 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 | = +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 +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. +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 + +# 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 | = + +# 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 +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 +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 +# 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) +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 +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 +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 +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. +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 +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 +# 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 | = +# 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 | = +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 +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 +mprotect | 0x10000000@unknown | region 4096 F|PROT_READ | err:EINVAL | 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 + +# 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 +# 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 | = +# 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 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(); diff --git a/tests/test-fd-lifecycle.c b/tests/test-fd-lifecycle.c index c4f28519..4b807106 100644 --- a/tests/test-fd-lifecycle.c +++ b/tests/test-fd-lifecycle.c @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -36,10 +38,22 @@ #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 +#ifndef F_SEAL_EXEC +#define F_SEAL_EXEC 0x0020 +#endif + int passes = 0, fails = 0; static int create_memfd(void) @@ -62,6 +76,87 @@ 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 fails with ENOMEM, so + * that answer and success are the two right ones. + */ +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 != 0 && map_errno != ENOMEM) + FAIL("mmap refused MAP_HUGETLB on a hugetlb memfd"); + else + 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"); @@ -303,6 +398,9 @@ 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_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(); diff --git a/tests/test-file-ops.c b/tests/test-file-ops.c index 3f05cec3..32d8b94d 100644 --- a/tests/test-file-ops.c +++ b/tests/test-file-ops.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,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 +174,104 @@ 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); + } + + /* 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. + */ + 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"); + } + + /* 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) { diff --git a/tests/test-flag-matrix.c b/tests/test-flag-matrix.c new file mode 100644 index 00000000..f6f241c8 --- /dev/null +++ b/tests/test-flag-matrix.c @@ -0,0 +1,836 @@ +/* + * 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 +#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_REGION, + FM_A_STACK, + 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, + FM_FD_SEALED, + FM_FD_UNLINKED, + FM_FD_PROCPATH +}; + +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_SEALS, + FM_X_FD_PERM, + 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, + 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, + 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, + FM_X_STACK_LOW_READONLY, + FM_X_STACK_LOW_WRITABLE, +}; + +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; +}; + +/* 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[] = { + [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 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 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) +{ + return (unsigned long) rc < (unsigned long) -4095L; +} +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(); + 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); + return -1; + } + return fd; +} + +/* 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_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_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; + 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_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_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_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_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) + 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 || 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; + 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; + 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, used_stack = 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)); + + /* 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); + 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]; + 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_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; + } + } + + 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. 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) { + 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. + */ + 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); + } + + 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. + */ + 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) + 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; + + 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"); + 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-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; } 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"