From f479677ca7a54a0e64e3695ae855c354b22c862a Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:29:20 +0800 Subject: [PATCH 01/15] Split the pty keepalive table procemu.c had grown to 4759 lines carrying three interceptors and a pty side table that shares nothing with them: /proc, /sys and /dev synthesis on one side, and on the other a keepalive table, a shared segment, slave accounting and the /dev/pts staging directory. The pty half moves out whole. procemu-internal.h carries the eight declarations the two halves still exchange, and the scratch-directory registry gains a named entry point so the pty side reaches it without the five statics behind it. --- Makefile | 9 +- src/runtime/procemu-internal.h | 54 ++ src/runtime/procemu-pty.c | 1519 +++++++++++++++++++++++++++++++ src/runtime/procemu.c | 1537 +------------------------------- 4 files changed, 1614 insertions(+), 1505 deletions(-) create mode 100644 src/runtime/procemu-internal.h create mode 100644 src/runtime/procemu-pty.c diff --git a/Makefile b/Makefile index ca5e489e..58ad9ce0 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ SRCS := \ runtime/forkipc.c \ runtime/fork-state.c \ runtime/procemu.c \ + runtime/procemu-pty.c \ runtime/proctitle.c \ syscall/syscall.c \ syscall/fdtable.c \ @@ -197,10 +198,10 @@ $(BUILD_DIR)/test-teardown-live-vcpu-host: \ @echo " LD $@" $(Q)$(CC) $(CFLAGS) -o $@ $^ $(HVF_LDFLAGS) -## Build the gva-math.h contract-check host test (native macOS binary) -# Header-only: gva-math.h is static inline, so the test links nothing from the -# project. It skips unless the build defines ELFUSE_CONTRACT_ASSERT, which is -# what "make check-contracts" does. +## Build the proved/gva.h contract-check host test (native macOS binary) +# Header-only: proved/gva.h is static inline, so the test links nothing +# from the project. It skips unless the build defines ELFUSE_CONTRACT_ASSERT, +# which is what "make check-contracts" does. $(BUILD_DIR)/test-gva-contracts: $(BUILD_DIR)/test-gva-contracts.o \ | $(BUILD_DIR) @echo " LD $@" diff --git a/src/runtime/procemu-internal.h b/src/runtime/procemu-internal.h new file mode 100644 index 00000000..c2b66bd9 --- /dev/null +++ b/src/runtime/procemu-internal.h @@ -0,0 +1,54 @@ +/* + * Interface between procemu.c and procemu-pty.c + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Not a public header. runtime/procemu.h is what the rest of the tree calls; + * this carries only what the two halves of the /proc, /sys, /dev interceptor + * need from each other after the pty side-table moved into its own file. + * + * It is deliberately short. If it grows, the split is in the wrong place. + */ + +#pragma once + +#include +#include +#include +#include + +/* Provided by procemu.c, used by procemu-pty.c. */ + +/* Open a directory the interceptor synthesized, honoring the guest's open + * flags. The pty code uses it for /dev/pts. + */ +int proc_open_dir_fd(const char *path, int linux_flags); + +/* Record a scratch directory for removal at exit. */ +void proc_scratch_register(const char *dir); + +/* Remove one lazily-created scratch directory. The pty code uses it to drop its + * /dev/pts staging directory at teardown. + */ +void proc_scratch_remove_one(const char *dir); + +/* Provided by procemu-pty.c, used by procemu.c. + * + * The proc_pty_* entry points are in runtime/procemu.h because callers outside + * procemu use them too. These four are internal to the interceptor: they were + * static before the split and stay unexported beyond this pair of files. + */ + +/* Parse the N out of "/dev/pts/N". False when the path is not a slave. */ +bool pty_slave_num_from_path(const char *path, uint32_t *out); + +/* Host path of a live Unix98 slave, or -1 when the pts number is unknown. */ +int pty_lookup_slave_path(uint32_t linux_pts_num, char *out, size_t out_sz); + +/* Open /dev/pts/N, /dev/pts, and /dev/ptmx respectively, with Linux open flags. + * Each allocates or adopts the keepalive state the side-table needs. + */ +int pty_open_slave(uint32_t linux_pts_num, int linux_flags); +int pty_open_pts_dir(int linux_flags); +int pty_open_master(int linux_flags); diff --git a/src/runtime/procemu-pty.c b/src/runtime/procemu-pty.c new file mode 100644 index 00000000..e8e58a76 --- /dev/null +++ b/src/runtime/procemu-pty.c @@ -0,0 +1,1519 @@ +/* + * Pseudoterminal master side-table + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Split out of runtime/procemu.c, which had grown to 4769 lines covering /proc, + * /sys, /dev, and this. The seam is real rather than convenient: every + * reference to the keepalive table and its shared segments lived inside one + * contiguous run, and the rest of procemu.c reaches it only through the + * proc_pty_* entry points already declared in runtime/procemu.h. + * + * What the table is for, and why it exists at all, is documented on + * pty_keepalive_table below. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.h" + +#include "debug/log.h" +#include "runtime/procemu.h" +#include "runtime/procemu-internal.h" + +#include "syscall/fd.h" +#include "syscall/internal.h" +#include "syscall/linux-wire.h" + +/* Pseudoterminal master side-table. + * + * Bridges two host vs guest mismatches in one place: + * + * 1. The macOS /dev/ptmx master is not itself a tty. TIOCSWINSZ / TIOCGWINSZ + * on the bare master return ENOTTY until something has opened the + * corresponding slave once, and the stored winsize gets cleared whenever + * the slave refcount drops to zero (verified empirically on macOS 15). + * Linux ptmx masters are tty fds in their own right, so guests assume those + * ioctls work without an open slave. To bridge the gap, every /dev/ptmx + * open eagerly opens one slave host fd that elfuse holds for the lifetime + * of the master and never exposes to the guest. + * + * 2. macOS slaves live at /dev/ttysNNN; Linux glibc looks for /dev/pts/N where + * N comes from TIOCGPTN. Guest opens of /dev/pts/N route back to the + * macOS path captured from ptsname(3) at /dev/ptmx open time, not a + * re-formatted guess, so format changes in macOS (or unusual minor + * encodings) cannot strand the guest with the wrong slave. + * + * Entries are keyed by the host master fd because that is what fd_cleanup_entry + * has when the guest closes a master. Capacity matches the macOS default UNIX98 + * slave count; overflow leaves the entry empty and the guest gets the pre-fix + * degraded behavior for that one pair instead of an open failure. + * + * Fork-restored entries may outlive their master for one /dev/pts/N open. A + * foot / sshd / posix-compliant child closes the master fd after fork before + * opening the slave (the child has no use for the master); without retaining + * the path mapping past close, the subsequent /dev/pts/N open in the child + * loses its translation and fails with ENOENT even though the parent still + * holds the master and the macOS slave node is openable. Those stale entries + * keep the received slave fd until the first translated open attempt, then + * expire before the minor can be reused for an unrelated host tty. Ordinary + * local master closes clear the mapping immediately. + */ +#define PTY_KEEPALIVE_MAX 256 + +/* macOS caps shm names (PSHMNAMLEN) at 31 bytes including the leading slash. */ +#define PTY_SHM_NAME_MAX 32 +#define PTY_KEEPALIVE_FREE (-1) + +/* Parse the N out of "/dev/pts/N". + * + * Returns false for the directory itself, a missing or non-numeric tail, or + * trailing garbage. + * + * Deliberately stricter than strtoul, which would take leading whitespace, a + * "+" sign and leading zeros. devpts dentries are decimal and canonical, so + * Linux answers ENOENT for "/dev/pts/016" even while slave 16 is open, and + * accepting the alias here would let one live slave answer under many names -- + * for its stat, its statfs identity, and for whether chmod and chown are + * intercepted at all. + */ +bool pty_slave_num_from_path(const char *path, uint32_t *out) +{ + if (!path || strncmp(path, "/dev/pts/", 9) != 0) + return false; + const char *digits = path + 9; + if (!*digits) + return false; + /* "0" is the only name that may start with a zero. */ + if (digits[0] == '0' && digits[1] != '\0') + return false; + + unsigned long n = 0; + for (const char *d = digits; *d; d++) { + if (*d < '0' || *d > '9') + return false; + if (n > (UINT32_MAX - (unsigned long) (*d - '0')) / 10) + return false; + n = n * 10 + (unsigned long) (*d - '0'); + } + if (out) + *out = (uint32_t) n; + return true; +} + +/* PTY_SLAVE_PATH_MAX lives in procemu.h so this table and the fork-IPC payload + * (proc_pty_ipc_entry_t) cannot drift apart. Cross-process slave accounting for + * one pty. + * + * The per-process counters below cannot answer the hangup question on their + * own, because a guest fork is a posix_spawn of a fresh elfuse process (see + * forkipc.c): the child gets its own keepalive table, so the slave a shell + * opens after the fork is invisible to the parent that owns the master and + * polls it. That is the whole terminal case -- foot holds the master and never + * opens a slave in that process -- so the master would never report a hangup. + * + * Anonymous shared memory cannot cross posix_spawn either, so this lives in a + * shm segment named after the host slave path, which is unique per host pty and + * which both sides already know: the parent from its own open, the child from + * the slave_path in the fork-IPC keepalive payload. No new IPC is needed. + * + * Counters are atomic rather than mutex-guarded on purpose: a process that dies + * holding a process-shared mutex would wedge every other process on this pty, + * and macOS has no robust mutexes. + */ +typedef struct { + _Atomic int32_t refs; /* elfuse processes holding a keepalive */ + _Atomic int32_t slave_count; /* guest-held slaves across all of them */ + _Atomic int32_t seen; /* a guest slave existed at least once */ +} pty_shared_t; + +static struct { + int master_host_fd; + int slave_host_fd; + uint32_t linux_pts_num; + bool stale_open_once; + + /* Slaves the guest has open, and whether it ever had one. Both are needed: + * a count of zero means hung up only after the first open. + * + * These stay per-process and are the fallback when the shared segment is + * unavailable (shm_open denied, for instance), which degrades to the + * same-process-only behavior rather than failing. guest_slave_count doubles + * as this process's contribution to shared->slave_count, so detaching can + * subtract it and stay balanced even when the guest exits without running + * per-fd cleanup. + */ + int guest_slave_count; + bool guest_slave_seen; + pty_shared_t *shared; + char slave_path[PTY_SLAVE_PATH_MAX]; +} pty_keepalive_table[PTY_KEEPALIVE_MAX]; + +/* Guest-held slave fds, so a master can report the hangup Linux gives once the + * last slave closes. + * + * elfuse keeps one slave open for the master's whole life (see the side-table + * header above), which is what stops macOS from ever hanging the master up: it + * only does so when *every* slave fd is gone. A guest terminal waiting for that + * hangup to learn its shell exited therefore waits forever, which is what + * happens to foot. Counting the slaves the guest itself holds lets sys_poll and + * sys_read answer for the pty layer instead of the host, without giving up the + * keepalive the tty ioctls need. + * + * The count only means anything once the guest has opened a slave at least + * once; before that a master with no slave is ordinary, not hung up. + */ +#define PTY_GUEST_SLAVE_MAX (PTY_KEEPALIVE_MAX * 4) +static struct { + int slave_host_fd; /* PTY_KEEPALIVE_FREE when the slot is unused */ + uint32_t linux_pts_num; +} pty_guest_slave_table[PTY_GUEST_SLAVE_MAX]; +static pthread_mutex_t pty_keepalive_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_once_t pty_keepalive_once = PTHREAD_ONCE_INIT; + +/* Derive the shm name for a pty from its host slave path. The basename is + * unique per host pty ("ttys004"), which is what makes the segment findable + * from a spawned child holding nothing but the path. macOS caps shm names at 31 + * bytes including the leading slash, so the prefix is kept short. Pty + * accounting trace. + * + * Writes to the file named by ELFUSE_PTY_LOG when set, in addition to the + * normal DEBUG log. The file matters because this subsystem spans processes: + * the parent holding the master and the child holding the slave are different + * elfuse instances, launched by a GUI app whose stderr goes nowhere reachable, + * so a shared append-only file with a pid tag is the only way to see both + * halves of a hangup decision in one place. + */ +__attribute__((format(printf, 1, 2))) static void pty_diag(const char *fmt, ...) +{ + static _Atomic int diag_fd = -2; /* -2 unopened, -1 disabled */ + int fd = atomic_load(&diag_fd); + if (fd == -2) { + const char *path = getenv("ELFUSE_PTY_LOG"); + int opened = -1; + if (path && path[0]) + opened = + open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); + int expected = -2; + if (!atomic_compare_exchange_strong(&diag_fd, &expected, opened)) { + if (opened >= 0) + close(opened); + fd = atomic_load(&diag_fd); + } else { + fd = opened; + } + } + if (fd < 0) + return; + + char msg[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(msg, sizeof(msg), fmt, ap); + va_end(ap); + if (n < 0) + return; + + char line[600]; + int m = snprintf(line, sizeof(line), "[pid %d] %s\n", (int) getpid(), msg); + if (m > 0) + (void) !write(fd, line, (size_t) m); +} + +static bool pty_shared_name(const char *slave_path, char *out, size_t out_sz) +{ + if (!slave_path || slave_path[0] == '\0') + return false; + const char *base = strrchr(slave_path, '/'); + base = base ? base + 1 : slave_path; + if (base[0] == '\0') + return false; + + /* Reject anything that is not a plain name so the path cannot inject a + * separator into the shm namespace. + */ + for (const char *p = base; *p; p++) { + if (!isalnum((unsigned char) *p) && *p != '_' && *p != '-') + return false; + } + int n = snprintf(out, out_sz, "/elfuse.pty.%s", base); + return n > 0 && (size_t) n < out_sz; +} + +/* Map this pty's shared counters, creating the segment when absent. + * + * fresh discards any segment left behind by a previous master on the same host + * pty: the path is only recycled once the host tty is fully released, so a + * surviving segment is stale state from a process that died without detaching. + * The fork-restore path passes false, since joining the parent's live segment + * is the entire point there. + * + * Returns NULL when the segment is unavailable; callers fall back to the + * per-process counters. + */ +static pty_shared_t *pty_shared_attach(const char *slave_path, bool fresh) +{ + char name[PTY_SHM_NAME_MAX]; + if (!pty_shared_name(slave_path, name, sizeof(name))) + return NULL; + if (fresh) + shm_unlink(name); + + bool created = true; + int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600); + if (fd < 0 && errno == EEXIST) { + created = false; + fd = shm_open(name, O_RDWR, 0600); + } + if (fd < 0) + return NULL; + + if (created && ftruncate(fd, sizeof(pty_shared_t)) < 0) { + close(fd); + shm_unlink(name); + return NULL; + } + if (!created) { + /* The creator sizes the segment just after shm_open, so a joiner that + * lands in that gap would map a zero-length object and take SIGBUS on + * first touch. Bail to the per-process fallback instead of waiting: + * this runs under fd_lock (proc_pty_master_adopt registers with both + * pty_keepalive_lock and fd_lock held), where sleeping would stall + * every fd operation in the process. + */ + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size < (off_t) sizeof(pty_shared_t)) { + close(fd); + return NULL; + } + } + + void *map = mmap(NULL, sizeof(pty_shared_t), PROT_READ | PROT_WRITE, + MAP_SHARED, fd, 0); + close(fd); + if (map == MAP_FAILED) + return NULL; + + pty_shared_t *sh = map; + atomic_fetch_add(&sh->refs, 1); + return sh; +} + +/* Drop this process's reference, handing back any slaves it still had counted, + * and unlink the segment once the last process lets go. + */ +static void pty_shared_detach(pty_shared_t *sh, + const char *slave_path, + int local_slave_count) +{ + if (!sh) + return; + if (local_slave_count > 0) { + pty_diag("pty: detach returns %d slave(s) path=%s", local_slave_count, + slave_path ? slave_path : "?"); + atomic_fetch_sub(&sh->slave_count, local_slave_count); + } + if (atomic_fetch_sub(&sh->refs, 1) == 1) { + char name[PTY_SHM_NAME_MAX]; + if (pty_shared_name(slave_path, name, sizeof(name))) + shm_unlink(name); + } + munmap(sh, sizeof(*sh)); +} + +/* Sentinel-init. Other fields stay BSS-zero; without sentinels a host fd 0 + * close would match slot 0 and close the wrong fd inside elfuse. + */ +static void pty_keepalive_init(void) +{ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + pty_keepalive_table[i].master_host_fd = PTY_KEEPALIVE_FREE; + pty_keepalive_table[i].guest_slave_count = 0; + pty_keepalive_table[i].guest_slave_seen = false; + pty_keepalive_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; + } +} + +static void pty_keepalive_lock_acquire(void) +{ + pthread_once(&pty_keepalive_once, pty_keepalive_init); + pthread_mutex_lock(&pty_keepalive_lock); +} + +/* Find a slot by master_host_fd; -1 if none. Caller holds the lock. */ +static int pty_keepalive_find_master_locked(int master_host_fd) +{ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) + if (pty_keepalive_table[i].master_host_fd == master_host_fd) + return i; + return -1; +} + +static int pty_keepalive_clear_slot_locked(int slot) +{ + int slave = pty_keepalive_table[slot].slave_host_fd; + pty_shared_detach(pty_keepalive_table[slot].shared, + pty_keepalive_table[slot].slave_path, + pty_keepalive_table[slot].guest_slave_count); + pty_keepalive_table[slot].shared = NULL; + pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; + pty_keepalive_table[slot].guest_slave_count = 0; + pty_keepalive_table[slot].guest_slave_seen = false; + pty_keepalive_table[slot].slave_host_fd = PTY_KEEPALIVE_FREE; + pty_keepalive_table[slot].linux_pts_num = 0; + pty_keepalive_table[slot].stale_open_once = false; + pty_keepalive_table[slot].slave_path[0] = '\0'; + return slave; +} + +/* Consume a stale entry's one-shot open without giving up its accounting. + * + * pty_open_slave retires the entry as soon as it has translated the + * close-before-open sequence, but the caller only records the guest slave + * afterwards. Clearing the slot outright detached the shared segment first, so + * that slave was credited to nobody and the master -- still held by the parent + * -- never learned the shell had one. What has to be consumed is the one-shot + * marker and the retained fd; the path, pts number and shared mapping stay so + * the slot remains the pty's accounting home, master-less, exactly as the + * record and release paths already expect. + * + * Returns the retained slave fd for the caller to close, or -1. + */ +static int pty_keepalive_retire_stale_locked(int slot) +{ + int slave = pty_keepalive_table[slot].slave_host_fd; + pty_keepalive_table[slot].slave_host_fd = PTY_KEEPALIVE_FREE; + pty_keepalive_table[slot].stale_open_once = false; + return slave; +} + +static uint32_t pty_extract_pts_num(const char *slave_path) +{ + /* macOS canonical slave paths are /dev/ttysNNN with a decimal tail. Read + * the longest decimal suffix and return it as the Linux pts number used by + * guest /dev/pts/N. + * + * Returns UINT32_MAX on parse failure so callers can reject ambiguous names + * rather than silently aliasing. + */ + if (!slave_path) + return UINT32_MAX; + const char *p = slave_path + strlen(slave_path); + while (p > slave_path && isdigit((unsigned char) p[-1])) + p--; + if (!*p || !isdigit((unsigned char) *p)) + return UINT32_MAX; + char *endp; + unsigned long n = strtoul(p, &endp, 10); + if (endp == p || *endp != '\0' || n > UINT32_MAX) + return UINT32_MAX; + return (uint32_t) n; +} + +/* Result codes for the locked register helper. */ +#define PTY_REG_INSERTED 0 /* new entry installed */ +#define PTY_REG_EXISTS 1 /* a matching entry already existed */ +#define PTY_REG_FULL (-1) /* table out of free slots */ + +/* Caller-holds-lock variant. + * + * Returns one of PTY_REG_* and, on PTY_REG_EXISTS, writes the existing entry's + * pts number to *existing_pts_num. The lock-held variant exists so + * proc_pty_master_adopt can atomically pair fd-table slot validation with + * keepalive insertion under fd_lock + pty_keepalive_lock, eliminating the race + * window where a sibling close+recycle between validate and register would + * attach the keepalive to the wrong file. + */ +static int pty_keepalive_register_locked(int master_host_fd, + int slave_host_fd, + uint32_t linux_pts_num, + const char *slave_path, + bool stale_open_once, + bool fresh_segment, + uint32_t *existing_pts_num) +{ + int empty_slot = -1; + int stale_path_slot = -1; + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd == master_host_fd) { + if (existing_pts_num) + *existing_pts_num = pty_keepalive_table[i].linux_pts_num; + return PTY_REG_EXISTS; + } + if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) + continue; + + /* Prefer a stale-path slot with the same pts number: the macOS minor + * deterministically maps to the same slave_path string, so reusing + * keeps lookups path-correct and bounds the table at one slot per live + * minor instead of accumulating a new entry on every reopen. + */ + if (pty_keepalive_table[i].slave_path[0] != '\0' && + pty_keepalive_table[i].linux_pts_num == linux_pts_num) { + stale_path_slot = i; + } else if (empty_slot < 0 && + pty_keepalive_table[i].slave_path[0] == '\0') { + empty_slot = i; + } + } + int slot = (stale_path_slot >= 0) ? stale_path_slot : empty_slot; + if (slot < 0) { + /* Out of empty slots and no stale-path match: evict the lowest-index + * stale-path entry so the live registration cannot starve. Live entries + * are never evicted. The eviction policy is approximately LRU: empty + * slots fill from low indices, so the lowest-index stale slot tends to + * be the oldest closed. A theoretical race exists with the + * close-before-open child pattern (a child stales slot K under + * pty_keepalive_lock and races into open("/dev/pts/N") just as another + * thread evicts slot K to register a different minor) but needs the + * keepalive table to be full -- live and stale entries both count -- + * with the staling thread's slot being the lowest-index stale. Well + * outside the foot / sshd workload that motivated this code. + */ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE && + pty_keepalive_table[i].slave_path[0] != '\0') { + slot = i; + break; + } + } + if (slot < 0) + return PTY_REG_FULL; + } + + /* Reusing a stale-path slot inherits its mapping; hand it back before the + * fields below are overwritten, or the reference and any slaves it still + * counted would be stranded in the segment. + */ + pty_shared_detach(pty_keepalive_table[slot].shared, + pty_keepalive_table[slot].slave_path, + pty_keepalive_table[slot].guest_slave_count); + pty_keepalive_table[slot].shared = NULL; + + pty_keepalive_table[slot].master_host_fd = master_host_fd; + pty_keepalive_table[slot].guest_slave_count = 0; + pty_keepalive_table[slot].guest_slave_seen = false; + if (pty_keepalive_table[slot].slave_host_fd >= 0 && + pty_keepalive_table[slot].slave_host_fd != slave_host_fd) + close(pty_keepalive_table[slot].slave_host_fd); + pty_keepalive_table[slot].slave_host_fd = slave_host_fd; + pty_keepalive_table[slot].linux_pts_num = linux_pts_num; + pty_keepalive_table[slot].stale_open_once = stale_open_once; + if (slave_path) + str_copy_trunc(pty_keepalive_table[slot].slave_path, slave_path, + PTY_SLAVE_PATH_MAX); + else + pty_keepalive_table[slot].slave_path[0] = '\0'; + + /* Only a pty the host just handed us gets a new segment. Every other + * registration -- a dup of a live master, an SCM_RIGHTS adopt, a + * fork-restore -- is one more reference to a pty that other processes may + * already be accounting for, and must join their segment. Discarding it + * would split the aliases onto separate counters, so slaves opened through + * one would be invisible to the other and the hangup would be lost. + */ + pty_keepalive_table[slot].shared = + pty_shared_attach(pty_keepalive_table[slot].slave_path, fresh_segment); + return PTY_REG_INSERTED; +} + +/* Lock-acquiring convenience wrapper used by the open-time and fork-restore + * paths where atomicity with fd_table is not required. + * + * Returns 0 on success (including PTY_REG_EXISTS, in which case the caller + * should close its own redundant slave_host_fd), -1 with errno set on + * table-full (ENOSPC). + */ +static int pty_keepalive_register(int master_host_fd, + int slave_host_fd, + uint32_t linux_pts_num, + const char *slave_path, + bool stale_open_once, + bool fresh_segment) +{ + pty_keepalive_lock_acquire(); + int rc = pty_keepalive_register_locked( + master_host_fd, slave_host_fd, linux_pts_num, slave_path, + stale_open_once, fresh_segment, NULL); + pthread_mutex_unlock(&pty_keepalive_lock); + if (rc == PTY_REG_FULL) { + errno = ENOSPC; + return -1; + } + if (rc == PTY_REG_EXISTS) + errno = EEXIST; + return 0; +} + +uint32_t proc_pty_master_pts_num(int master_host_fd) +{ + if (master_host_fd < 0) + return UINT32_MAX; + pty_keepalive_lock_acquire(); + int slot = pty_keepalive_find_master_locked(master_host_fd); + uint32_t pts_num = + (slot < 0) ? UINT32_MAX : pty_keepalive_table[slot].linux_pts_num; + pthread_mutex_unlock(&pty_keepalive_lock); + return pts_num; +} + +/* Re-validate that fd_table[guest_fd] still refers to (host_fd, generation). + * Returns true when both match the snapshot, false otherwise (slot closed or + * recycled). Used by proc_pty_master_adopt to bracket every host-fd-number + * access against the closing-and-reuse race. + */ +static bool pty_fd_still_canonical(int guest_fd, + int canonical_host_fd, + uint64_t canonical_gen) +{ + fd_entry_t snap; + if (!fd_snapshot(guest_fd, &snap)) + return false; + return snap.host_fd == canonical_host_fd && + snap.generation == canonical_gen; +} + +uint32_t proc_pty_master_adopt(int guest_fd) +{ + /* Step 1: atomically snapshot (host_fd, generation) and dup the canonical + * fd in a single fd_lock window. fd_snapshot_and_dup pins the file object + * behind the canonical host fd, so even if a sibling closes the guest fd + * and the host fd number is recycled by an unrelated open, host syscalls + * against the probe still operate on the right tty. The generation captured + * here is the witness for the subsequent table lookup and register + * validations. + */ + fd_entry_t snap; + int probe = fd_snapshot_and_dup(guest_fd, &snap); + if (probe < 0) + return UINT32_MAX; + int canonical_host_fd = snap.host_fd; + uint64_t canonical_gen = snap.generation; + + /* Fast path: a keepalive was already registered for this canonical fd + * (typical case for /dev/ptmx opens that went through pty_open_master). The + * keepalive table is keyed by host fd number, so re-validate the slot + * identity before trusting the returned pts_num. If the fd has been + * recycled to a different file (generation mismatch), the existing entry + * belongs to that file, not the pinned probe, and the slow path below must + * register a fresh entry for the pinned probe. + */ + uint32_t existing = proc_pty_master_pts_num(canonical_host_fd); + if (existing != UINT32_MAX && + pty_fd_still_canonical(guest_fd, canonical_host_fd, canonical_gen)) { + close(probe); + return existing; + } + + /* Step 2: confirm the file really is a /dev/ptmx master. ptsname(3) returns + * NULL/ENOTTY on non-pty descriptors, so a stray TIOCGPTN against a regular + * file is rejected without any side effect. + */ + char slave_path[PTY_SLAVE_PATH_MAX]; + uint32_t pts_num = UINT32_MAX; + int slave; + if (ptsname_r(probe, slave_path, sizeof(slave_path)) != 0) + goto out; + pts_num = pty_extract_pts_num(slave_path); + if (pts_num == UINT32_MAX) + goto out; + + /* unlockpt(3) is harmless if the sender already unlocked. EINVAL means + * already unlocked; anything else means the slave will not open and we give + * up cleanly. + */ + if (unlockpt(probe) < 0 && errno != EINVAL) { + pts_num = UINT32_MAX; + goto out; + } + slave = open(slave_path, O_RDWR | O_NOCTTY | O_CLOEXEC); + if (slave < 0) { + pts_num = UINT32_MAX; + goto out; + } + + /* Step 3: re-validate AND publish under the joint pty_keepalive_lock + + * fd_lock window. Lock order is pty_keepalive_lock first; + * duplicate_guest_fd uses the same order when bracketing + * fd_snapshot_and_dup + proc_pty_dup_keepalive_locked, so the two paths + * cannot deadlock. With both held, no sibling can flip the fd_table slot + * between the validation read and the keepalive insert, so the keepalive + * cannot attach to a recycled canonical host fd. + */ + pty_keepalive_lock_acquire(); + pthread_mutex_lock(&fd_lock); + if (fd_table[guest_fd].type == FD_CLOSED || + fd_table[guest_fd].host_fd != canonical_host_fd || + fd_table[guest_fd].generation != canonical_gen) { + pthread_mutex_unlock(&fd_lock); + pthread_mutex_unlock(&pty_keepalive_lock); + close(slave); + pts_num = UINT32_MAX; + goto out; + } + uint32_t existing_pts = UINT32_MAX; + + /* Adopting a master elfuse did not open: the pty already exists and other + * processes may hold its segment, so join rather than replace. + */ + int rc = + pty_keepalive_register_locked(canonical_host_fd, slave, pts_num, + slave_path, false, false, &existing_pts); + pthread_mutex_unlock(&fd_lock); + pthread_mutex_unlock(&pty_keepalive_lock); + if (rc == PTY_REG_FULL) { + close(slave); + pts_num = UINT32_MAX; + } else if (rc == PTY_REG_EXISTS) { + /* Another adopter registered first; their slave keeps the tty alive. + * The pts_num came from the locked scan above, so it is the value the + * winning entry holds and is not subject to a lookup-after-recycle + * race. + */ + close(slave); + pts_num = existing_pts; + } + +out: + close(probe); + return pts_num; +} + +/* Look up the captured macOS slave path for a Linux pts number. + * + * Returns 0 and writes the path on hit, -1 with errno=ENOENT on miss. Used by + * the /dev/pts/N open and stat intercepts so they hit the exact path returned + * by ptsname(3) rather than a guessed /dev/ttys%03lu reformat that breaks if + * macOS changes its naming scheme or uses an unexpected minor encoding. + */ +int pty_lookup_slave_path(uint32_t linux_pts_num, char *out, size_t out_sz) +{ + if (!out || out_sz == 0) { + errno = EINVAL; + return -1; + } + int hit = -1; + pty_keepalive_lock_acquire(); + + /* Prefer a live entry (master still open in this process) over a stale path + * entry. Both encode the same slave_path for a given minor on macOS, so the + * preference only matters if a future change ever lets the two diverge - + * live wins by breaking out of the scan on first match. + */ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].linux_pts_num != linux_pts_num) + continue; + if (pty_keepalive_table[i].slave_path[0] == '\0') + continue; + if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) { + hit = i; + break; + } + if (!pty_keepalive_table[i].stale_open_once || + pty_keepalive_table[i].slave_host_fd < 0) + continue; + if (hit < 0) + hit = i; + } + if (hit < 0) { + pthread_mutex_unlock(&pty_keepalive_lock); + errno = ENOENT; + return -1; + } + size_t len = strlen(pty_keepalive_table[hit].slave_path); + if (len >= out_sz) { + pthread_mutex_unlock(&pty_keepalive_lock); + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, pty_keepalive_table[hit].slave_path, len + 1); + pthread_mutex_unlock(&pty_keepalive_lock); + return 0; +} + +bool proc_pty_slave_stat(const char *path, struct stat *out) +{ + if (!path || strncmp(path, "/dev/pts/", 9) != 0 || !path[9]) + return false; + struct stat st; + if (proc_intercept_stat(path, out ? out : &st) != 0) + return false; + return true; +} + +/* The guest-slave table is zero-initialized, so mark every slot free the first + * time it is touched: fd 0 is a legitimate host descriptor and must not read as + * an occupied slot. + */ +static void pty_guest_slave_table_init_once(void) +{ + static bool done; + if (done) + return; + for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) + pty_guest_slave_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; + done = true; +} + +/* Retire a recorded slave fd and credit its master. Caller holds the lock. */ +static void pty_guest_slave_release_locked(int slave_host_fd) +{ + for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { + if (pty_guest_slave_table[i].slave_host_fd != slave_host_fd) + continue; + uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; + pty_guest_slave_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; + for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { + /* A slot whose master already closed still owns this slave's + * accounting: the guest can drop the master and keep the slave as + * its stdio, and that slave has to be able to give its count back. + * Matching on the retained pts number covers both states; a fully + * cleared slot has neither a path nor a mapping and cannot match. + */ + if (pty_keepalive_table[k].slave_path[0] == '\0') + continue; + if (pty_keepalive_table[k].linux_pts_num != pts_num) + continue; + if (pty_keepalive_table[k].guest_slave_count > 0) { + pty_keepalive_table[k].guest_slave_count--; + pty_diag( + "pty: -slave pts=%u hostfd=%d local=%d shared=%d", pts_num, + slave_host_fd, pty_keepalive_table[k].guest_slave_count, + pty_keepalive_table[k].shared + ? atomic_load( + &pty_keepalive_table[k].shared->slave_count) - + 1 + : -1); + if (pty_keepalive_table[k].shared) + atomic_fetch_sub( + &pty_keepalive_table[k].shared->slave_count, 1); + } + break; + } + break; + } +} + +/* Put a slave fd on this process's books and credit its master. Caller holds + * the lock. + * + * bump_shared is false only for a slave inherited through fork: the parent + * already added it to the shared count on the child's behalf (see + * proc_pty_fork_parent_note_inherited), so counting it again here would double + * it. The local count still rises either way, since it is this process's + * contribution and what its closes and its detach subtract. + */ +static void pty_guest_slave_record_locked(int slave_host_fd, + uint32_t linux_pts_num, + bool bump_shared) +{ + for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { + if (pty_guest_slave_table[i].slave_host_fd != PTY_KEEPALIVE_FREE) + continue; + pty_guest_slave_table[i].slave_host_fd = slave_host_fd; + pty_guest_slave_table[i].linux_pts_num = linux_pts_num; + for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { + /* Match the rule the release path uses: a slot whose master has + * already closed still owns this pty's accounting. A fork-restored + * child routinely drops its copy of the master and only then opens + * /dev/pts/N, and requiring a live master here left that slave + * credited to nobody -- so the parent, still holding the master, + * never learned the shell had one. A fully cleared slot keeps + * neither a path nor a mapping and cannot match. + */ + if (pty_keepalive_table[k].slave_path[0] == '\0') + continue; + if (pty_keepalive_table[k].linux_pts_num != linux_pts_num) + continue; + pty_keepalive_table[k].guest_slave_count++; + pty_keepalive_table[k].guest_slave_seen = true; + pty_diag( + "pty: +slave pts=%u hostfd=%d bump_shared=%d local=%d " + "shared=%d", + linux_pts_num, slave_host_fd, (int) bump_shared, + pty_keepalive_table[k].guest_slave_count, + pty_keepalive_table[k].shared + ? atomic_load(&pty_keepalive_table[k].shared->slave_count) + + (bump_shared ? 1 : 0) + : -1); + if (pty_keepalive_table[k].shared) { + if (bump_shared) + atomic_fetch_add( + &pty_keepalive_table[k].shared->slave_count, 1); + atomic_store(&pty_keepalive_table[k].shared->seen, 1); + } + break; + } + break; + } +} + +static void pty_note_guest_slave(int slave_host_fd, + uint32_t linux_pts_num, + bool bump_shared) +{ + if (slave_host_fd < 0) + return; + pty_keepalive_lock_acquire(); + pty_guest_slave_table_init_once(); + + /* Drop any entry left over for this host fd number first. The open is + * recorded before the guest fd is installed, so a failed fd_alloc closes + * the host fd without passing through the close hooks; retiring the stale + * slot on reuse keeps that from inflating an unrelated pty's count. + */ + pty_guest_slave_release_locked(slave_host_fd); + pty_guest_slave_record_locked(slave_host_fd, linux_pts_num, bump_shared); + pthread_mutex_unlock(&pty_keepalive_lock); +} + +void proc_pty_note_guest_slave(int slave_host_fd, uint32_t linux_pts_num) +{ + pty_note_guest_slave(slave_host_fd, linux_pts_num, true); +} + +void proc_pty_fork_parent_note_inherited(void) +{ + /* fork duplicates every slave fd the guest holds, so the child's copies are + * live the instant fork returns. Count them here, in the parent, while the + * guest is still inside clone: leaving it to the child's own init loses the + * race against a parent that closes its copy immediately, which is exactly + * what openpty(3)-style terminal startup does. The pty would look hung up + * in that window and the terminal would see its shell die at startup. + */ + pty_keepalive_lock_acquire(); + + /* The sentinel init is what makes an unused slot readable as free. Without + * it a table still in its BSS-zero state reads as PTY_GUEST_SLAVE_MAX + * occupied slots holding host fd 0, and every one of them would be counted + * as an inherited slave -- which is what a parent that never opened a slave + * itself does on its very first fork. + */ + pty_guest_slave_table_init_once(); + for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { + if (pty_guest_slave_table[i].slave_host_fd == PTY_KEEPALIVE_FREE) + continue; + uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; + for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { + if (pty_keepalive_table[k].master_host_fd == PTY_KEEPALIVE_FREE) + continue; + if (pty_keepalive_table[k].linux_pts_num != pts_num) + continue; + if (pty_keepalive_table[k].shared) + atomic_fetch_add(&pty_keepalive_table[k].shared->slave_count, + 1); + break; + } + } + pthread_mutex_unlock(&pty_keepalive_lock); +} + +void proc_pty_dup_guest_slave_locked(int src_slave_host_fd, + int dst_slave_host_fd) +{ + if (src_slave_host_fd < 0 || dst_slave_host_fd < 0) + return; + pty_guest_slave_table_init_once(); + + uint32_t pts_num = UINT32_MAX; + for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { + if (pty_guest_slave_table[i].slave_host_fd == src_slave_host_fd) { + pts_num = pty_guest_slave_table[i].linux_pts_num; + break; + } + } + if (pts_num == UINT32_MAX) + return; /* not a tracked slave; nothing to mirror */ + + /* The dup is a live reference to the same slave, so it has to be counted + * like the open that produced the source. Only open() used to register, so + * a terminal that dup2()s its slave onto stdin/stdout/stderr and closes the + * original left the count at zero with three references still open -- the + * master then reported a hangup with the shell still running. + */ + pty_guest_slave_release_locked(dst_slave_host_fd); + pty_guest_slave_record_locked(dst_slave_host_fd, pts_num, true); +} + +void proc_pty_release_process_slaves(void) +{ + /* Hand back every slave this process still holds, at process teardown. + * + * Per-fd cleanup cannot be relied on for this: a shell exiting normally + * never closes its stdio, the kernel does, so the slaves backing fds 0/1/2 + * leave no close hook behind. Without this the shared count keeps a + * departed shell's slaves forever and the master never reports the hangup + * its terminal is waiting on -- the "window stays open after exit" case. + * + * A process killed outright still cannot run this, and leaks its + * contribution. That is bounded: the host pty is only recycled once every + * fd on it is gone, and the next master to claim that path starts a fresh + * segment (see pty_shared_attach), so the stale count is discarded rather + * than inherited. + */ + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (!pty_keepalive_table[i].shared) + continue; + pty_shared_detach(pty_keepalive_table[i].shared, + pty_keepalive_table[i].slave_path, + pty_keepalive_table[i].guest_slave_count); + pty_keepalive_table[i].shared = NULL; + pty_keepalive_table[i].guest_slave_count = 0; + } + pthread_mutex_unlock(&pty_keepalive_lock); +} + +void proc_pty_adopt_inherited_slaves(void) +{ + /* A guest fork hands the child every slave fd the parent had open, but the + * table that maps a host fd back to its pty is per-process and does not + * travel, so those inherited slaves were counted by nobody. The parent then + * closes its own copy -- exactly what openpty(3)-style startup does -- the + * count falls to zero while the child's shell still holds a live slave, and + * the master reports a hangup the instant the terminal window appears. + * + * Recover the mapping from the host instead of shipping more state: a pty + * slave is a char device whose rdev matches the slave path recorded in the + * keepalive entry, which the child has just restored. Runs in the forked + * child's single-threaded init, after the fd table and the keepalives. + */ + struct { + uint32_t pts_num; + dev_t rdev; + int skip_slave_fd; + int skip_master_fd; + } ptys[PTY_KEEPALIVE_MAX]; + int npty = 0; + + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX && npty < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE) + continue; + if (pty_keepalive_table[i].slave_path[0] == '\0') + continue; + struct stat st; + if (stat(pty_keepalive_table[i].slave_path, &st) != 0 || + !S_ISCHR(st.st_mode)) + continue; + ptys[npty].pts_num = pty_keepalive_table[i].linux_pts_num; + ptys[npty].rdev = st.st_rdev; + ptys[npty].skip_slave_fd = pty_keepalive_table[i].slave_host_fd; + ptys[npty].skip_master_fd = pty_keepalive_table[i].master_host_fd; + npty++; + } + pthread_mutex_unlock(&pty_keepalive_lock); + if (npty == 0) + return; + + /* Snapshot the host fds before matching: proc_pty_note_guest_slave takes + * pty_keepalive_lock, which sorts before fd_lock, so neither lock can be + * held while calling it. + */ + int host_fds[FD_TABLE_SIZE]; + int nfd = 0; + pthread_mutex_lock(&fd_lock); + for (int gfd = 0; gfd < FD_TABLE_SIZE; gfd++) { + if (fd_table[gfd].type == FD_CLOSED || fd_table[gfd].host_fd < 0) + continue; + host_fds[nfd++] = fd_table[gfd].host_fd; + } + pthread_mutex_unlock(&fd_lock); + + for (int i = 0; i < nfd; i++) { + struct stat st; + if (fstat(host_fds[i], &st) != 0 || !S_ISCHR(st.st_mode)) + continue; + for (int p = 0; p < npty; p++) { + if (st.st_rdev != ptys[p].rdev) + continue; + + /* elfuse's own keepalive slave is not a guest slave, and the master + * never matches the slave's rdev but is cheap to exclude. + */ + if (host_fds[i] == ptys[p].skip_slave_fd || + host_fds[i] == ptys[p].skip_master_fd) + break; + + /* Local books only: the parent already counted these copies into + * the shared total before fork returned. + */ + pty_note_guest_slave(host_fds[i], ptys[p].pts_num, false); + break; + } + } +} + +void proc_pty_slave_fd_closed(int host_fd) +{ + if (host_fd < 0) + return; + pty_keepalive_lock_acquire(); + pty_guest_slave_table_init_once(); + pty_guest_slave_release_locked(host_fd); + pthread_mutex_unlock(&pty_keepalive_lock); +} + +/* Whether this slot's pty has no guest slave left. Reads the shared segment + * when one is mapped, so a slave held by another process in the fork family + * counts; falls back to the per-process view when it is not. Caller holds + * pty_keepalive_lock. + */ +static bool pty_slot_hung_up_locked(int slot) +{ + pty_shared_t *sh = pty_keepalive_table[slot].shared; + bool hung_up; + if (sh) + hung_up = + atomic_load(&sh->seen) != 0 && atomic_load(&sh->slave_count) <= 0; + else + hung_up = pty_keepalive_table[slot].guest_slave_seen && + pty_keepalive_table[slot].guest_slave_count == 0; + + /* Only on the way to reporting one: the negative answer is the steady state + * and every poll would log it. This subsystem spans processes, so without a + * record of which side saw what a wrong verdict is very hard to place after + * the fact. + */ + if (hung_up) + pty_diag("pty: HANGUP pts=%u seen=%d shared=%d local=%d/%d path=%s", + pty_keepalive_table[slot].linux_pts_num, + sh ? atomic_load(&sh->seen) : -1, + sh ? atomic_load(&sh->slave_count) : -1, + (int) pty_keepalive_table[slot].guest_slave_seen, + pty_keepalive_table[slot].guest_slave_count, + pty_keepalive_table[slot].slave_path); + return hung_up; +} + +bool proc_pty_master_hung_up(int guest_fd, uint64_t expect_generation) +{ + /* Keyed on the guest fd rather than a host one: callers reach the master + * through host_fd_ref, which hands out a dup, and the keepalive table is + * keyed by the canonical host fd that dup does not share. + */ + fd_entry_t snap; + if (!fd_snapshot(guest_fd, &snap)) + return false; + + /* The caller resolved this guest fd earlier; re-resolving it here reopens + * the close-and-reuse window. Reject a slot that has been recycled since, + * so the hangup is never charged to an unrelated file. + */ + if (snap.generation != expect_generation) + return false; + int master_host_fd = snap.host_fd; + if (master_host_fd < 0) + return false; + bool hung_up = false; + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd != master_host_fd) + continue; + hung_up = pty_slot_hung_up_locked(i); + break; + } + pthread_mutex_unlock(&pty_keepalive_lock); + return hung_up; +} + +int pty_open_slave(uint32_t linux_pts_num, int linux_flags) +{ + int oflags = translate_open_flags(linux_flags) & + (O_ACCMODE | O_NONBLOCK | O_CLOEXEC | O_NOCTTY); + char host_path[PTY_SLAVE_PATH_MAX]; + int stale_hit = -1; + int retained_slaves[PTY_KEEPALIVE_MAX]; + int nretained = 0; + int fd; + + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].linux_pts_num != linux_pts_num) + continue; + if (pty_keepalive_table[i].slave_path[0] == '\0') + continue; + if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) { + size_t len = strlen(pty_keepalive_table[i].slave_path); + if (len >= sizeof(host_path)) { + pthread_mutex_unlock(&pty_keepalive_lock); + errno = ENAMETOOLONG; + return -1; + } + memcpy(host_path, pty_keepalive_table[i].slave_path, len + 1); + pthread_mutex_unlock(&pty_keepalive_lock); + return open(host_path, oflags); + } + if (stale_hit < 0 && pty_keepalive_table[i].stale_open_once && + pty_keepalive_table[i].slave_host_fd >= 0) + stale_hit = i; + } + + if (stale_hit < 0) { + pthread_mutex_unlock(&pty_keepalive_lock); + errno = ENOENT; + return -1; + } + + /* Stale fork-child entries are one-shot. The retained slave fd pins the + * macOS tty while we translate the close-before-open sequence, preventing + * the cached path from resolving to a reused unrelated minor. Regardless of + * open success, consume the stale mapping before returning. + */ + size_t len = strlen(pty_keepalive_table[stale_hit].slave_path); + if (len >= sizeof(host_path)) { + int retained_slave = pty_keepalive_retire_stale_locked(stale_hit); + pthread_mutex_unlock(&pty_keepalive_lock); + if (retained_slave >= 0) + close(retained_slave); + errno = ENAMETOOLONG; + return -1; + } + memcpy(host_path, pty_keepalive_table[stale_hit].slave_path, len + 1); + fd = open(host_path, oflags); + int saved = errno; + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) + continue; + if (!pty_keepalive_table[i].stale_open_once) + continue; + if (strncmp(pty_keepalive_table[i].slave_path, host_path, + PTY_SLAVE_PATH_MAX) != 0) + continue; + int retained_slave = pty_keepalive_retire_stale_locked(i); + if (retained_slave >= 0 && nretained < PTY_KEEPALIVE_MAX) + retained_slaves[nretained++] = retained_slave; + } + pthread_mutex_unlock(&pty_keepalive_lock); + for (int i = 0; i < nretained; i++) + close(retained_slaves[i]); + errno = saved; + return fd; +} + +int pty_open_pts_dir(int linux_flags) +{ + char dir[80]; + uint32_t pts_nums[PTY_KEEPALIVE_MAX]; + int pts_count = 0; + int n = snprintf(dir, sizeof(dir), "/tmp/elfuse-pts-XXXXXX"); + if (n < 0 || (size_t) n >= sizeof(dir)) { + errno = ENAMETOOLONG; + return -1; + } + if (!mkdtemp(dir)) + return -1; + + pty_keepalive_lock_acquire(); + + /* Enumerate live masters and fork-child one-shot stale entries. The stale + * entries retain a slave fd until the first open attempt consumes them, so + * they cannot name a reused unrelated tty while they appear in readdir. + */ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].slave_path[0] == '\0') + continue; + if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE && + (!pty_keepalive_table[i].stale_open_once || + pty_keepalive_table[i].slave_host_fd < 0)) + continue; + + /* The recycle/reuse-by-pts_num invariant in + * pty_keepalive_register_locked keeps at most one entry per minor, so + * no de-duplication pass is needed here. + */ + pts_nums[pts_count++] = pty_keepalive_table[i].linux_pts_num; + } + pthread_mutex_unlock(&pty_keepalive_lock); + + for (int i = 0; i < pts_count; i++) { + char entry[160]; + int en = snprintf(entry, sizeof(entry), "%s/%u", dir, pts_nums[i]); + if (en <= 0 || (size_t) en >= sizeof(entry)) + continue; + int tfd = open(entry, O_CREAT | O_WRONLY, 0444); + if (tfd >= 0) + close(tfd); + } + + proc_scratch_register(dir); + + int fd = proc_open_dir_fd(dir, linux_flags); + if (fd < 0) { + int saved = errno; + proc_scratch_remove_one(dir); + errno = saved; + } + return fd; +} + +void proc_pty_lock_for_dup(void) +{ + pty_keepalive_lock_acquire(); +} + +void proc_pty_unlock_for_dup(void) +{ + pthread_mutex_unlock(&pty_keepalive_lock); +} + +void proc_pty_dup_keepalive_locked(int src_master_host_fd, + int dst_master_host_fd) +{ + /* Caller-holds-lock variant; see header for the dup race this guards. */ + if (src_master_host_fd < 0 || dst_master_host_fd < 0) + return; + + int slot = pty_keepalive_find_master_locked(src_master_host_fd); + if (slot < 0) + return; + int dst_slave = dup(pty_keepalive_table[slot].slave_host_fd); + if (dst_slave < 0) + return; + uint32_t src_pts_num = pty_keepalive_table[slot].linux_pts_num; + char src_slave_path[PTY_SLAVE_PATH_MAX]; + memcpy(src_slave_path, pty_keepalive_table[slot].slave_path, + PTY_SLAVE_PATH_MAX); + + /* dup(2) clears FD_CLOEXEC; the keepalive must not survive exec into a + * guest child that has no map back to it. + */ + if (fd_set_cloexec(dst_slave) < 0) { + close(dst_slave); + return; + } + int rc = pty_keepalive_register_locked(dst_master_host_fd, dst_slave, + src_pts_num, src_slave_path, false, + /*fresh_segment=*/false, NULL); + if (rc != PTY_REG_INSERTED) { + /* Table full or duplicate entry for dst_master_host_fd; drop the + * redundant slave. Duplicate is unexpected: dst is a freshly-duped host + * fd that should not already be in the table unless a prior close + * skipped proc_pty_close_keepalive. + */ + close(dst_slave); + } +} + +void proc_pty_close_keepalive(int master_host_fd) +{ + /* fd_cleanup_entry calls this for every guest fd close, not just pty + * masters; pty_keepalive_lock_acquire guarantees sentinel-init first. + */ + if (master_host_fd < 0) + return; + + int slave = -1; + pty_keepalive_lock_acquire(); + int slot = pty_keepalive_find_master_locked(master_host_fd); + if (slot >= 0) { + if (pty_keepalive_table[slot].stale_open_once) { + /* Fork-restored child entry: retain the slave fd and path for one + * /dev/pts/N open after close(master). pty_open_slave consumes and + * closes it on the first translated open attempt. Only the master + * goes away here. Any slave fd this process still holds stays open + * and keeps counting: closing the master does not close the slaves, + * and a terminal's child routinely drops its copy of the master + * while holding the slave as its stdio. Retiring the count here + * would report a hangup with the shell still running. The slaves + * decrement themselves as they close. + */ + pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; + } else { + slave = pty_keepalive_clear_slot_locked(slot); + } + } + pthread_mutex_unlock(&pty_keepalive_lock); + if (slave >= 0) + close(slave); +} + +static void proc_pty_expire_stale_by_path(const char *slave_path) +{ + if (!slave_path || slave_path[0] == '\0') + return; + + int stale_slaves[PTY_KEEPALIVE_MAX]; + int nslaves = 0; + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) + continue; + if (!pty_keepalive_table[i].stale_open_once) + continue; + if (strncmp(pty_keepalive_table[i].slave_path, slave_path, + PTY_SLAVE_PATH_MAX) != 0) + continue; + int slave = pty_keepalive_clear_slot_locked(i); + if (slave >= 0 && nslaves < PTY_KEEPALIVE_MAX) + stale_slaves[nslaves++] = slave; + } + pthread_mutex_unlock(&pty_keepalive_lock); + for (int i = 0; i < nslaves; i++) + close(stale_slaves[i]); +} + +static int pty_keepalive_register_recycled(int master_host_fd, + int slave_host_fd, + uint32_t linux_pts_num, + const char *slave_path, + bool stale_open_once, + bool fresh_segment) +{ + proc_pty_expire_stale_by_path(slave_path); + return pty_keepalive_register(master_host_fd, slave_host_fd, linux_pts_num, + slave_path, stale_open_once, fresh_segment); +} + +int proc_pty_snapshot_keepalive(proc_pty_ipc_entry_t *out_entries, + int *out_slave_fds, + int max_entries) +{ + if (!out_entries || !out_slave_fds || max_entries <= 0) + return 0; + + int n = 0; + pty_keepalive_lock_acquire(); + for (int i = 0; i < PTY_KEEPALIVE_MAX && n < max_entries; i++) { + if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE) + continue; + + /* dup under the lock so the slave fd cannot be closed and the host fd + * number recycled before SCM_RIGHTS reads it. The caller closes the dup + * after the send completes. + */ + int duped = dup(pty_keepalive_table[i].slave_host_fd); + if (duped < 0) + continue; + + out_entries[n].master_host_fd = pty_keepalive_table[i].master_host_fd; + out_entries[n].linux_pts_num = pty_keepalive_table[i].linux_pts_num; + _Static_assert(sizeof(out_entries[n].slave_path) == PTY_SLAVE_PATH_MAX, + "ipc slave_path size must match keepalive table"); + memcpy(out_entries[n].slave_path, pty_keepalive_table[i].slave_path, + PTY_SLAVE_PATH_MAX); + out_slave_fds[n] = duped; + n++; + } + pthread_mutex_unlock(&pty_keepalive_lock); + return n; +} + +void proc_pty_restore_keepalive(int master_host_fd, + int slave_host_fd, + uint32_t linux_pts_num, + const char *slave_path) +{ + /* fork-IPC hand-off. SCM_RIGHTS drops FD_CLOEXEC; set it here so the + * keepalive does not survive exec. Any failure drops the slave fd. + */ + if (master_host_fd < 0) + goto drop; + + if (slave_host_fd >= 0 && fd_set_cloexec(slave_host_fd) < 0) + goto drop; + + /* Trust the parent's linux_pts_num verbatim instead of re-parsing + * slave_path. The wire-format string is bounded to PTY_SLAVE_PATH_MAX - 1 + * bytes; if a future macOS canonical form ever exceeded that, the parent + * would have truncated and reparsing here would yield the wrong number. On + * EEXIST the child's fd_table-restore path replayed master_host_fd over a + * prior recv-keepalive entry; drop the redundant slave so it does not leak. + */ + errno = 0; + if (pty_keepalive_register_recycled(master_host_fd, slave_host_fd, + linux_pts_num, slave_path, true, + /*fresh_segment=*/false) < 0 || + errno == EEXIST) + goto drop; + return; + +drop: + if (slave_host_fd >= 0) + close(slave_host_fd); +} + +/* Open /dev/ptmx, unlock the slave, and instantiate a keepalive slave fd so the + * master's tty ioctls work before the guest opens the slave itself. + * Returns the master host fd on success, -1 with errno set on failure. + */ +int pty_open_master(int linux_flags) +{ + /* /dev/ptmx is a character device; O_CREAT / O_TRUNC / O_EXCL make no sense + * here. Strip them and only honor accmode + descriptor flags so the host + * open(2) never sees a variadic-mode-required combination without a mode + * arg. + */ + int oflags = translate_open_flags(linux_flags) & + (O_ACCMODE | O_NONBLOCK | O_CLOEXEC | O_NOCTTY); + int master = open("/dev/ptmx", oflags); + if (master < 0) + return -1; + + /* grantpt(3) is a no-op on a unix98 pty mount, but call it for clarity and + * to match what posix_openpt(3)'s callers expect to have happened. + */ + char slave_path[PTY_SLAVE_PATH_MAX]; + if (grantpt(master) < 0 || unlockpt(master) < 0 || + ptsname_r(master, slave_path, sizeof(slave_path)) != 0) { + close_keep_errno(master); + return -1; + } + + /* Establish the (linux_pts_num, slave_path) mapping that /dev/pts/N opens + * and stats resolve through. If table or slave-fd registration fails after + * the master is open, report EMFILE rather than silently returning a master + * fd whose pts number cannot be opened back through /dev/pts/N. The caller + * can close other pty pairs and retry instead of dealing with a half-broken + * descriptor. + */ + uint32_t linux_pts_num = pty_extract_pts_num(slave_path); + if (linux_pts_num == UINT32_MAX) { + close(master); + errno = ENOTTY; + return -1; + } + int slave = open(slave_path, O_RDWR | O_NOCTTY | O_CLOEXEC); + if (slave < 0) { + close_keep_errno(master); + return -1; + } + errno = 0; + + /* The host just allocated this pty, so nothing live can be using a segment + * under its name; any leftover is state a process died holding. + */ + if (pty_keepalive_register_recycled(master, slave, linux_pts_num, + slave_path, false, + /*fresh_segment=*/true) < 0) { + close(slave); + close(master); + errno = EMFILE; + return -1; + } + + /* Defense-in-depth: the freshly-opened master fd should not already have a + * keepalive (would indicate a stale entry from a prior close that did not + * run proc_pty_close_keepalive). Drop the redundant slave so it does not + * leak. + */ + if (errno == EEXIST) + close(slave); + return master; +} diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index 0acb4aef..f314a630 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -45,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -56,14 +55,13 @@ #include #include #include -#include -#include #include "string-builder.h" #include "utils.h" #include "debug/log.h" #include "runtime/procemu.h" +#include "runtime/procemu-internal.h" #include "core/rosetta.h" #include "runtime/thread.h" @@ -450,7 +448,6 @@ static void proc_oom_refresh_live_fds_locked(void) } } -static int proc_open_dir_fd(const char *path, int linux_flags); static int proc_lazy_mkdtemp(char *buf, size_t buf_size, const char *template); static int append_proc_net_row(char *buf, size_t bufsz, @@ -484,7 +481,7 @@ static int proc_scratch_dirs_count; static pthread_mutex_t proc_scratch_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_once_t proc_scratch_atexit_once = PTHREAD_ONCE_INIT; -static void proc_scratch_remove_one(const char *dir) +void proc_scratch_remove_one(const char *dir) { DIR *d = opendir(dir); if (d) { @@ -518,6 +515,22 @@ static void proc_scratch_register_atexit(void) atexit(proc_scratch_cleanup_atexit); } +/* Record a scratch directory for removal at exit, arming the atexit hook on + * first use. The registry is private to this file; procemu-pty.c reaches it + * through here rather than through the five statics behind it. + */ +void proc_scratch_register(const char *dir) +{ + pthread_once(&proc_scratch_atexit_once, proc_scratch_register_atexit); + + pthread_mutex_lock(&proc_scratch_lock); + if (proc_scratch_dirs_count < PROC_SCRATCH_DIRS_MAX) { + str_copy_trunc(proc_scratch_dirs[proc_scratch_dirs_count++], dir, + sizeof(proc_scratch_dirs[0])); + } + pthread_mutex_unlock(&proc_scratch_lock); +} + /* Open a per-call scratch directory populated with one empty file per live * guest fd. * @@ -551,14 +564,7 @@ static int proc_open_fd_scratch(const char *prefix, int linux_flags) close(tfd); } - pthread_once(&proc_scratch_atexit_once, proc_scratch_register_atexit); - - pthread_mutex_lock(&proc_scratch_lock); - if (proc_scratch_dirs_count < PROC_SCRATCH_DIRS_MAX) { - str_copy_trunc(proc_scratch_dirs[proc_scratch_dirs_count++], dir, - sizeof(proc_scratch_dirs[0])); - } - pthread_mutex_unlock(&proc_scratch_lock); + proc_scratch_register(dir); int fd = proc_open_dir_fd(dir, linux_flags); if (fd < 0) { @@ -1218,7 +1224,7 @@ static int proc_parse_int_write(const void *buf, size_t count, int *out) return 0; } -static int proc_open_dir_fd(const char *path, int linux_flags) +int proc_open_dir_fd(const char *path, int linux_flags) { int oflags = O_RDONLY | O_DIRECTORY; @@ -1687,1492 +1693,6 @@ static void proc_task_collect_cb(thread_entry_t *t, void *arg) c->tids[c->ntids++] = t->guest_tid; } -/* Pseudoterminal master side-table. - * - * Bridges two host vs guest mismatches in one place: - * - * 1. The macOS /dev/ptmx master is not itself a tty. TIOCSWINSZ / TIOCGWINSZ - * on the bare master return ENOTTY until something has opened the - * corresponding slave once, and the stored winsize gets cleared whenever - * the slave refcount drops to zero (verified empirically on macOS 15). - * Linux ptmx masters are tty fds in their own right, so guests assume those - * ioctls work without an open slave. To bridge the gap, every /dev/ptmx - * open eagerly opens one slave host fd that elfuse holds for the lifetime - * of the master and never exposes to the guest. - * - * 2. macOS slaves live at /dev/ttysNNN; Linux glibc looks for /dev/pts/N where - * N comes from TIOCGPTN. Guest opens of /dev/pts/N route back to the - * macOS path captured from ptsname(3) at /dev/ptmx open time, not a - * re-formatted guess, so format changes in macOS (or unusual minor - * encodings) cannot strand the guest with the wrong slave. - * - * Entries are keyed by the host master fd because that is what fd_cleanup_entry - * has when the guest closes a master. Capacity matches the macOS default UNIX98 - * slave count; overflow leaves the entry empty and the guest gets the pre-fix - * degraded behavior for that one pair instead of an open failure. - * - * Fork-restored entries may outlive their master for one /dev/pts/N open. A - * foot / sshd / posix-compliant child closes the master fd after fork before - * opening the slave (the child has no use for the master); without retaining - * the path mapping past close, the subsequent /dev/pts/N open in the child - * loses its translation and fails with ENOENT even though the parent still - * holds the master and the macOS slave node is openable. Those stale entries - * keep the received slave fd until the first translated open attempt, then - * expire before the minor can be reused for an unrelated host tty. Ordinary - * local master closes clear the mapping immediately. - */ -#define PTY_KEEPALIVE_MAX 256 - -/* macOS caps shm names (PSHMNAMLEN) at 31 bytes including the leading slash. */ -#define PTY_SHM_NAME_MAX 32 -#define PTY_KEEPALIVE_FREE (-1) - -/* Group that owns pty slaves. Linux distributions mount devpts with gid=5 - * ("tty") and glibc's grantpt(3) looks that group up before deciding whether - * the slave needs chowning. - */ -#define PTY_SLAVE_TTY_GID 5u - -/* Parse the N out of "/dev/pts/N". - * - * Returns false for the directory itself, a missing or non-numeric tail, or - * trailing garbage. - * - * Deliberately stricter than strtoul, which would take leading whitespace, a - * "+" sign and leading zeros. devpts dentries are decimal and canonical, so - * Linux answers ENOENT for "/dev/pts/016" even while slave 16 is open, and - * accepting the alias here would let one live slave answer under many names -- - * for its stat, its statfs identity, and for whether chmod and chown are - * intercepted at all. - */ -static bool pty_slave_num_from_path(const char *path, uint32_t *out) -{ - if (!path || strncmp(path, "/dev/pts/", 9) != 0) - return false; - const char *digits = path + 9; - if (!*digits) - return false; - /* "0" is the only name that may start with a zero. */ - if (digits[0] == '0' && digits[1] != '\0') - return false; - - unsigned long n = 0; - for (const char *d = digits; *d; d++) { - if (*d < '0' || *d > '9') - return false; - if (n > (UINT32_MAX - (unsigned long) (*d - '0')) / 10) - return false; - n = n * 10 + (unsigned long) (*d - '0'); - } - if (out) - *out = (uint32_t) n; - return true; -} - -/* PTY_SLAVE_PATH_MAX lives in procemu.h so this table and the fork-IPC payload - * (proc_pty_ipc_entry_t) cannot drift apart. - */ -/* Cross-process slave accounting for one pty. - * - * The per-process counters below cannot answer the hangup question on their - * own, because a guest fork is a posix_spawn of a fresh elfuse process (see - * forkipc.c): the child gets its own keepalive table, so the slave a shell - * opens after the fork is invisible to the parent that owns the master and - * polls it. That is the whole terminal case -- foot holds the master and never - * opens a slave in that process -- so the master would never report a hangup. - * - * Anonymous shared memory cannot cross posix_spawn either, so this lives in a - * shm segment named after the host slave path, which is unique per host pty and - * which both sides already know: the parent from its own open, the child from - * the slave_path in the fork-IPC keepalive payload. No new IPC is needed. - * - * Counters are atomic rather than mutex-guarded on purpose: a process that dies - * holding a process-shared mutex would wedge every other process on this pty, - * and macOS has no robust mutexes. - */ -typedef struct { - _Atomic int32_t refs; /* elfuse processes holding a keepalive */ - _Atomic int32_t slave_count; /* guest-held slaves across all of them */ - _Atomic int32_t seen; /* a guest slave existed at least once */ -} pty_shared_t; - -static struct { - int master_host_fd; - int slave_host_fd; - uint32_t linux_pts_num; - bool stale_open_once; - - /* Slaves the guest has open, and whether it ever had one. Both are needed: - * a count of zero means hung up only after the first open. - * - * These stay per-process and are the fallback when the shared segment is - * unavailable (shm_open denied, for instance), which degrades to the - * same-process-only behavior rather than failing. guest_slave_count doubles - * as this process's contribution to shared->slave_count, so detaching can - * subtract it and stay balanced even when the guest exits without running - * per-fd cleanup. - */ - int guest_slave_count; - bool guest_slave_seen; - pty_shared_t *shared; - char slave_path[PTY_SLAVE_PATH_MAX]; -} pty_keepalive_table[PTY_KEEPALIVE_MAX]; - -/* Guest-held slave fds, so a master can report the hangup Linux gives once the - * last slave closes. - * - * elfuse keeps one slave open for the master's whole life (see the side-table - * header above), which is what stops macOS from ever hanging the master up: it - * only does so when *every* slave fd is gone. A guest terminal waiting for that - * hangup to learn its shell exited therefore waits forever, which is what - * happens to foot. Counting the slaves the guest itself holds lets sys_poll and - * sys_read answer for the pty layer instead of the host, without giving up the - * keepalive the tty ioctls need. - * - * The count only means anything once the guest has opened a slave at least - * once; before that a master with no slave is ordinary, not hung up. - */ -#define PTY_GUEST_SLAVE_MAX (PTY_KEEPALIVE_MAX * 4) -static struct { - int slave_host_fd; /* PTY_KEEPALIVE_FREE when the slot is unused */ - uint32_t linux_pts_num; -} pty_guest_slave_table[PTY_GUEST_SLAVE_MAX]; -static pthread_mutex_t pty_keepalive_lock = PTHREAD_MUTEX_INITIALIZER; -static pthread_once_t pty_keepalive_once = PTHREAD_ONCE_INIT; - -/* Derive the shm name for a pty from its host slave path. The basename is - * unique per host pty ("ttys004"), which is what makes the segment findable - * from a spawned child holding nothing but the path. macOS caps shm names at 31 - * bytes including the leading slash, so the prefix is kept short. - */ -/* Pty accounting trace. - * - * Writes to the file named by ELFUSE_PTY_LOG when set, in addition to the - * normal DEBUG log. The file matters because this subsystem spans processes: - * the parent holding the master and the child holding the slave are different - * elfuse instances, launched by a GUI app whose stderr goes nowhere reachable, - * so a shared append-only file with a pid tag is the only way to see both - * halves of a hangup decision in one place. - */ -__attribute__((format(printf, 1, 2))) static void pty_diag(const char *fmt, ...) -{ - static _Atomic int diag_fd = -2; /* -2 unopened, -1 disabled */ - int fd = atomic_load(&diag_fd); - if (fd == -2) { - const char *path = getenv("ELFUSE_PTY_LOG"); - int opened = -1; - if (path && path[0]) - opened = - open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); - int expected = -2; - if (!atomic_compare_exchange_strong(&diag_fd, &expected, opened)) { - if (opened >= 0) - close(opened); - fd = atomic_load(&diag_fd); - } else { - fd = opened; - } - } - if (fd < 0) - return; - - char msg[512]; - va_list ap; - va_start(ap, fmt); - int n = vsnprintf(msg, sizeof(msg), fmt, ap); - va_end(ap); - if (n < 0) - return; - - char line[600]; - int m = snprintf(line, sizeof(line), "[pid %d] %s\n", (int) getpid(), msg); - if (m > 0) - (void) !write(fd, line, (size_t) m); -} - -static bool pty_shared_name(const char *slave_path, char *out, size_t out_sz) -{ - if (!slave_path || slave_path[0] == '\0') - return false; - const char *base = strrchr(slave_path, '/'); - base = base ? base + 1 : slave_path; - if (base[0] == '\0') - return false; - /* Reject anything that is not a plain name so the path cannot inject a - * separator into the shm namespace. - */ - for (const char *p = base; *p; p++) { - if (!isalnum((unsigned char) *p) && *p != '_' && *p != '-') - return false; - } - int n = snprintf(out, out_sz, "/elfuse.pty.%s", base); - return n > 0 && (size_t) n < out_sz; -} - -/* Map this pty's shared counters, creating the segment when absent. - * - * fresh discards any segment left behind by a previous master on the same host - * pty: the path is only recycled once the host tty is fully released, so a - * surviving segment is stale state from a process that died without detaching. - * The fork-restore path passes false, since joining the parent's live segment - * is the entire point there. - * - * Returns NULL when the segment is unavailable; callers fall back to the - * per-process counters. - */ -static pty_shared_t *pty_shared_attach(const char *slave_path, bool fresh) -{ - char name[PTY_SHM_NAME_MAX]; - if (!pty_shared_name(slave_path, name, sizeof(name))) - return NULL; - if (fresh) - shm_unlink(name); - - bool created = true; - int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600); - if (fd < 0 && errno == EEXIST) { - created = false; - fd = shm_open(name, O_RDWR, 0600); - } - if (fd < 0) - return NULL; - - if (created && ftruncate(fd, sizeof(pty_shared_t)) < 0) { - close(fd); - shm_unlink(name); - return NULL; - } - if (!created) { - /* The creator sizes the segment just after shm_open, so a joiner that - * lands in that gap would map a zero-length object and take SIGBUS on - * first touch. Bail to the per-process fallback instead of waiting: - * this runs under fd_lock (proc_pty_master_adopt registers with both - * pty_keepalive_lock and fd_lock held), where sleeping would stall - * every fd operation in the process. - */ - struct stat st; - if (fstat(fd, &st) != 0 || st.st_size < (off_t) sizeof(pty_shared_t)) { - close(fd); - return NULL; - } - } - - void *map = mmap(NULL, sizeof(pty_shared_t), PROT_READ | PROT_WRITE, - MAP_SHARED, fd, 0); - close(fd); - if (map == MAP_FAILED) - return NULL; - - pty_shared_t *sh = map; - atomic_fetch_add(&sh->refs, 1); - return sh; -} - -/* Drop this process's reference, handing back any slaves it still had counted, - * and unlink the segment once the last process lets go. - */ -static void pty_shared_detach(pty_shared_t *sh, - const char *slave_path, - int local_slave_count) -{ - if (!sh) - return; - if (local_slave_count > 0) { - pty_diag("pty: detach returns %d slave(s) path=%s", local_slave_count, - slave_path ? slave_path : "?"); - atomic_fetch_sub(&sh->slave_count, local_slave_count); - } - if (atomic_fetch_sub(&sh->refs, 1) == 1) { - char name[PTY_SHM_NAME_MAX]; - if (pty_shared_name(slave_path, name, sizeof(name))) - shm_unlink(name); - } - munmap(sh, sizeof(*sh)); -} - -/* Sentinel-init. Other fields stay BSS-zero; without sentinels a host fd 0 - * close would match slot 0 and close the wrong fd inside elfuse. - */ -static void pty_keepalive_init(void) -{ - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - pty_keepalive_table[i].master_host_fd = PTY_KEEPALIVE_FREE; - pty_keepalive_table[i].guest_slave_count = 0; - pty_keepalive_table[i].guest_slave_seen = false; - pty_keepalive_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; - } -} - -static void pty_keepalive_lock_acquire(void) -{ - pthread_once(&pty_keepalive_once, pty_keepalive_init); - pthread_mutex_lock(&pty_keepalive_lock); -} - -/* Find a slot by master_host_fd; -1 if none. Caller holds the lock. */ -static int pty_keepalive_find_master_locked(int master_host_fd) -{ - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) - if (pty_keepalive_table[i].master_host_fd == master_host_fd) - return i; - return -1; -} - -static int pty_keepalive_clear_slot_locked(int slot) -{ - int slave = pty_keepalive_table[slot].slave_host_fd; - pty_shared_detach(pty_keepalive_table[slot].shared, - pty_keepalive_table[slot].slave_path, - pty_keepalive_table[slot].guest_slave_count); - pty_keepalive_table[slot].shared = NULL; - pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; - pty_keepalive_table[slot].guest_slave_count = 0; - pty_keepalive_table[slot].guest_slave_seen = false; - pty_keepalive_table[slot].slave_host_fd = PTY_KEEPALIVE_FREE; - pty_keepalive_table[slot].linux_pts_num = 0; - pty_keepalive_table[slot].stale_open_once = false; - pty_keepalive_table[slot].slave_path[0] = '\0'; - return slave; -} - -/* Consume a stale entry's one-shot open without giving up its accounting. - * - * pty_open_slave retires the entry as soon as it has translated the - * close-before-open sequence, but the caller only records the guest slave - * afterwards. Clearing the slot outright detached the shared segment first, so - * that slave was credited to nobody and the master -- still held by the parent - * -- never learned the shell had one. What has to be consumed is the one-shot - * marker and the retained fd; the path, pts number and shared mapping stay so - * the slot remains the pty's accounting home, master-less, exactly as the - * record and release paths already expect. - * - * Returns the retained slave fd for the caller to close, or -1. - */ -static int pty_keepalive_retire_stale_locked(int slot) -{ - int slave = pty_keepalive_table[slot].slave_host_fd; - pty_keepalive_table[slot].slave_host_fd = PTY_KEEPALIVE_FREE; - pty_keepalive_table[slot].stale_open_once = false; - return slave; -} - -static uint32_t pty_extract_pts_num(const char *slave_path) -{ - /* macOS canonical slave paths are /dev/ttysNNN with a decimal tail. Read - * the longest decimal suffix and return it as the Linux pts number used by - * guest /dev/pts/N. - * - * Returns UINT32_MAX on parse failure so callers can reject ambiguous names - * rather than silently aliasing. - */ - if (!slave_path) - return UINT32_MAX; - const char *p = slave_path + strlen(slave_path); - while (p > slave_path && isdigit((unsigned char) p[-1])) - p--; - if (!*p || !isdigit((unsigned char) *p)) - return UINT32_MAX; - char *endp; - unsigned long n = strtoul(p, &endp, 10); - if (endp == p || *endp != '\0' || n > UINT32_MAX) - return UINT32_MAX; - return (uint32_t) n; -} - -/* Result codes for the locked register helper. */ -#define PTY_REG_INSERTED 0 /* new entry installed */ -#define PTY_REG_EXISTS 1 /* a matching entry already existed */ -#define PTY_REG_FULL (-1) /* table out of free slots */ - -/* Caller-holds-lock variant. - * - * Returns one of PTY_REG_* and, on PTY_REG_EXISTS, writes the existing entry's - * pts number to *existing_pts_num. The lock-held variant exists so - * proc_pty_master_adopt can atomically pair fd-table slot validation with - * keepalive insertion under fd_lock + pty_keepalive_lock, eliminating the race - * window where a sibling close+recycle between validate and register would - * attach the keepalive to the wrong file. - */ -static int pty_keepalive_register_locked(int master_host_fd, - int slave_host_fd, - uint32_t linux_pts_num, - const char *slave_path, - bool stale_open_once, - bool fresh_segment, - uint32_t *existing_pts_num) -{ - int empty_slot = -1; - int stale_path_slot = -1; - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd == master_host_fd) { - if (existing_pts_num) - *existing_pts_num = pty_keepalive_table[i].linux_pts_num; - return PTY_REG_EXISTS; - } - if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) - continue; - - /* Prefer a stale-path slot with the same pts number: the macOS minor - * deterministically maps to the same slave_path string, so reusing - * keeps lookups path-correct and bounds the table at one slot per live - * minor instead of accumulating a new entry on every reopen. - */ - if (pty_keepalive_table[i].slave_path[0] != '\0' && - pty_keepalive_table[i].linux_pts_num == linux_pts_num) { - stale_path_slot = i; - } else if (empty_slot < 0 && - pty_keepalive_table[i].slave_path[0] == '\0') { - empty_slot = i; - } - } - int slot = (stale_path_slot >= 0) ? stale_path_slot : empty_slot; - if (slot < 0) { - /* Out of empty slots and no stale-path match: evict the lowest-index - * stale-path entry so the live registration cannot starve. Live entries - * are never evicted. The eviction policy is approximately LRU: empty - * slots fill from low indices, so the lowest-index stale slot tends to - * be the oldest closed. A theoretical race exists with the - * close-before-open child pattern (a child stales slot K under - * pty_keepalive_lock and races into open("/dev/pts/N") just as another - * thread evicts slot K to register a different minor) but needs the - * keepalive table to be full -- live and stale entries both count -- - * with the staling thread's slot being the lowest-index stale. Well - * outside the foot / sshd workload that motivated this code. - */ - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE && - pty_keepalive_table[i].slave_path[0] != '\0') { - slot = i; - break; - } - } - if (slot < 0) - return PTY_REG_FULL; - } - /* Reusing a stale-path slot inherits its mapping; hand it back before the - * fields below are overwritten, or the reference and any slaves it still - * counted would be stranded in the segment. - */ - pty_shared_detach(pty_keepalive_table[slot].shared, - pty_keepalive_table[slot].slave_path, - pty_keepalive_table[slot].guest_slave_count); - pty_keepalive_table[slot].shared = NULL; - - pty_keepalive_table[slot].master_host_fd = master_host_fd; - pty_keepalive_table[slot].guest_slave_count = 0; - pty_keepalive_table[slot].guest_slave_seen = false; - if (pty_keepalive_table[slot].slave_host_fd >= 0 && - pty_keepalive_table[slot].slave_host_fd != slave_host_fd) - close(pty_keepalive_table[slot].slave_host_fd); - pty_keepalive_table[slot].slave_host_fd = slave_host_fd; - pty_keepalive_table[slot].linux_pts_num = linux_pts_num; - pty_keepalive_table[slot].stale_open_once = stale_open_once; - if (slave_path) - str_copy_trunc(pty_keepalive_table[slot].slave_path, slave_path, - PTY_SLAVE_PATH_MAX); - else - pty_keepalive_table[slot].slave_path[0] = '\0'; - - /* Only a pty the host just handed us gets a new segment. Every other - * registration -- a dup of a live master, an SCM_RIGHTS adopt, a - * fork-restore -- is one more reference to a pty that other processes may - * already be accounting for, and must join their segment. Discarding it - * would split the aliases onto separate counters, so slaves opened through - * one would be invisible to the other and the hangup would be lost. - */ - pty_keepalive_table[slot].shared = - pty_shared_attach(pty_keepalive_table[slot].slave_path, fresh_segment); - return PTY_REG_INSERTED; -} - -/* Lock-acquiring convenience wrapper used by the open-time and fork-restore - * paths where atomicity with fd_table is not required. - * - * Returns 0 on success (including PTY_REG_EXISTS, in which case the caller - * should close its own redundant slave_host_fd), -1 with errno set on - * table-full (ENOSPC). - */ -static int pty_keepalive_register(int master_host_fd, - int slave_host_fd, - uint32_t linux_pts_num, - const char *slave_path, - bool stale_open_once, - bool fresh_segment) -{ - pty_keepalive_lock_acquire(); - int rc = pty_keepalive_register_locked( - master_host_fd, slave_host_fd, linux_pts_num, slave_path, - stale_open_once, fresh_segment, NULL); - pthread_mutex_unlock(&pty_keepalive_lock); - if (rc == PTY_REG_FULL) { - errno = ENOSPC; - return -1; - } - if (rc == PTY_REG_EXISTS) - errno = EEXIST; - return 0; -} - -uint32_t proc_pty_master_pts_num(int master_host_fd) -{ - if (master_host_fd < 0) - return UINT32_MAX; - pty_keepalive_lock_acquire(); - int slot = pty_keepalive_find_master_locked(master_host_fd); - uint32_t pts_num = - (slot < 0) ? UINT32_MAX : pty_keepalive_table[slot].linux_pts_num; - pthread_mutex_unlock(&pty_keepalive_lock); - return pts_num; -} - -/* Re-validate that fd_table[guest_fd] still refers to (host_fd, generation). - * Returns true when both match the snapshot, false otherwise (slot closed or - * recycled). Used by proc_pty_master_adopt to bracket every host-fd-number - * access against the closing-and-reuse race. - */ -static bool pty_fd_still_canonical(int guest_fd, - int canonical_host_fd, - uint64_t canonical_gen) -{ - fd_entry_t snap; - if (!fd_snapshot(guest_fd, &snap)) - return false; - return snap.host_fd == canonical_host_fd && - snap.generation == canonical_gen; -} - -uint32_t proc_pty_master_adopt(int guest_fd) -{ - /* Step 1: atomically snapshot (host_fd, generation) and dup the canonical - * fd in a single fd_lock window. fd_snapshot_and_dup pins the file object - * behind the canonical host fd, so even if a sibling closes the guest fd - * and the host fd number is recycled by an unrelated open, host syscalls - * against the probe still operate on the right tty. The generation captured - * here is the witness for the subsequent table lookup and register - * validations. - */ - fd_entry_t snap; - int probe = fd_snapshot_and_dup(guest_fd, &snap); - if (probe < 0) - return UINT32_MAX; - int canonical_host_fd = snap.host_fd; - uint64_t canonical_gen = snap.generation; - - /* Fast path: a keepalive was already registered for this canonical fd - * (typical case for /dev/ptmx opens that went through pty_open_master). The - * keepalive table is keyed by host fd number, so re-validate the slot - * identity before trusting the returned pts_num. If the fd has been - * recycled to a different file (generation mismatch), the existing entry - * belongs to that file, not the pinned probe, and the slow path below must - * register a fresh entry for the pinned probe. - */ - uint32_t existing = proc_pty_master_pts_num(canonical_host_fd); - if (existing != UINT32_MAX && - pty_fd_still_canonical(guest_fd, canonical_host_fd, canonical_gen)) { - close(probe); - return existing; - } - - /* Step 2: confirm the file really is a /dev/ptmx master. ptsname(3) returns - * NULL/ENOTTY on non-pty descriptors, so a stray TIOCGPTN against a regular - * file is rejected without any side effect. - */ - char slave_path[PTY_SLAVE_PATH_MAX]; - uint32_t pts_num = UINT32_MAX; - int slave; - if (ptsname_r(probe, slave_path, sizeof(slave_path)) != 0) - goto out; - pts_num = pty_extract_pts_num(slave_path); - if (pts_num == UINT32_MAX) - goto out; - - /* unlockpt(3) is harmless if the sender already unlocked. EINVAL means - * already unlocked; anything else means the slave will not open and we give - * up cleanly. - */ - if (unlockpt(probe) < 0 && errno != EINVAL) { - pts_num = UINT32_MAX; - goto out; - } - slave = open(slave_path, O_RDWR | O_NOCTTY | O_CLOEXEC); - if (slave < 0) { - pts_num = UINT32_MAX; - goto out; - } - - /* Step 3: re-validate AND publish under the joint pty_keepalive_lock + - * fd_lock window. Lock order is pty_keepalive_lock first; - * duplicate_guest_fd uses the same order when bracketing - * fd_snapshot_and_dup + proc_pty_dup_keepalive_locked, so the two paths - * cannot deadlock. With both held, no sibling can flip the fd_table slot - * between the validation read and the keepalive insert, so the keepalive - * cannot attach to a recycled canonical host fd. - */ - pty_keepalive_lock_acquire(); - pthread_mutex_lock(&fd_lock); - if (fd_table[guest_fd].type == FD_CLOSED || - fd_table[guest_fd].host_fd != canonical_host_fd || - fd_table[guest_fd].generation != canonical_gen) { - pthread_mutex_unlock(&fd_lock); - pthread_mutex_unlock(&pty_keepalive_lock); - close(slave); - pts_num = UINT32_MAX; - goto out; - } - uint32_t existing_pts = UINT32_MAX; - /* Adopting a master elfuse did not open: the pty already exists and other - * processes may hold its segment, so join rather than replace. - */ - int rc = - pty_keepalive_register_locked(canonical_host_fd, slave, pts_num, - slave_path, false, false, &existing_pts); - pthread_mutex_unlock(&fd_lock); - pthread_mutex_unlock(&pty_keepalive_lock); - if (rc == PTY_REG_FULL) { - close(slave); - pts_num = UINT32_MAX; - } else if (rc == PTY_REG_EXISTS) { - /* Another adopter registered first; their slave keeps the tty alive. - * The pts_num came from the locked scan above, so it is the value the - * winning entry holds and is not subject to a lookup-after-recycle - * race. - */ - close(slave); - pts_num = existing_pts; - } - -out: - close(probe); - return pts_num; -} - -/* Look up the captured macOS slave path for a Linux pts number. - * - * Returns 0 and writes the path on hit, -1 with errno=ENOENT on miss. Used by - * the /dev/pts/N open and stat intercepts so they hit the exact path returned - * by ptsname(3) rather than a guessed /dev/ttys%03lu reformat that breaks if - * macOS changes its naming scheme or uses an unexpected minor encoding. - */ -static int pty_lookup_slave_path(uint32_t linux_pts_num, - char *out, - size_t out_sz) -{ - if (!out || out_sz == 0) { - errno = EINVAL; - return -1; - } - int hit = -1; - pty_keepalive_lock_acquire(); - - /* Prefer a live entry (master still open in this process) over a stale path - * entry. Both encode the same slave_path for a given minor on macOS, so the - * preference only matters if a future change ever lets the two diverge - - * live wins by breaking out of the scan on first match. - */ - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].linux_pts_num != linux_pts_num) - continue; - if (pty_keepalive_table[i].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) { - hit = i; - break; - } - if (!pty_keepalive_table[i].stale_open_once || - pty_keepalive_table[i].slave_host_fd < 0) - continue; - if (hit < 0) - hit = i; - } - if (hit < 0) { - pthread_mutex_unlock(&pty_keepalive_lock); - errno = ENOENT; - return -1; - } - size_t len = strlen(pty_keepalive_table[hit].slave_path); - if (len >= out_sz) { - pthread_mutex_unlock(&pty_keepalive_lock); - errno = ENAMETOOLONG; - return -1; - } - memcpy(out, pty_keepalive_table[hit].slave_path, len + 1); - pthread_mutex_unlock(&pty_keepalive_lock); - return 0; -} - -bool proc_pty_slave_stat(const char *path, struct stat *out) -{ - if (!path || strncmp(path, "/dev/pts/", 9) != 0 || !path[9]) - return false; - struct stat st; - if (proc_intercept_stat(path, out ? out : &st) != 0) - return false; - return true; -} - -/* The guest-slave table is zero-initialized, so mark every slot free the first - * time it is touched: fd 0 is a legitimate host descriptor and must not read as - * an occupied slot. - */ -static void pty_guest_slave_table_init_once(void) -{ - static bool done; - if (done) - return; - for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) - pty_guest_slave_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; - done = true; -} - -/* Retire a recorded slave fd and credit its master. Caller holds the lock. */ -static void pty_guest_slave_release_locked(int slave_host_fd) -{ - for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { - if (pty_guest_slave_table[i].slave_host_fd != slave_host_fd) - continue; - uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; - pty_guest_slave_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - /* A slot whose master already closed still owns this slave's - * accounting: the guest can drop the master and keep the slave as - * its stdio, and that slave has to be able to give its count back. - * Matching on the retained pts number covers both states; a fully - * cleared slot has neither a path nor a mapping and cannot match. - */ - if (pty_keepalive_table[k].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[k].linux_pts_num != pts_num) - continue; - if (pty_keepalive_table[k].guest_slave_count > 0) { - pty_keepalive_table[k].guest_slave_count--; - pty_diag( - "pty: -slave pts=%u hostfd=%d local=%d shared=%d", pts_num, - slave_host_fd, pty_keepalive_table[k].guest_slave_count, - pty_keepalive_table[k].shared - ? atomic_load( - &pty_keepalive_table[k].shared->slave_count) - - 1 - : -1); - if (pty_keepalive_table[k].shared) - atomic_fetch_sub( - &pty_keepalive_table[k].shared->slave_count, 1); - } - break; - } - break; - } -} - -/* Put a slave fd on this process's books and credit its master. Caller holds - * the lock. - * - * bump_shared is false only for a slave inherited through fork: the parent - * already added it to the shared count on the child's behalf (see - * proc_pty_fork_parent_note_inherited), so counting it again here would double - * it. The local count still rises either way, since it is this process's - * contribution and what its closes and its detach subtract. - */ -static void pty_guest_slave_record_locked(int slave_host_fd, - uint32_t linux_pts_num, - bool bump_shared) -{ - for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { - if (pty_guest_slave_table[i].slave_host_fd != PTY_KEEPALIVE_FREE) - continue; - pty_guest_slave_table[i].slave_host_fd = slave_host_fd; - pty_guest_slave_table[i].linux_pts_num = linux_pts_num; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - /* Match the rule the release path uses: a slot whose master has - * already closed still owns this pty's accounting. A fork-restored - * child routinely drops its copy of the master and only then opens - * /dev/pts/N, and requiring a live master here left that slave - * credited to nobody -- so the parent, still holding the master, - * never learned the shell had one. A fully cleared slot keeps - * neither a path nor a mapping and cannot match. - */ - if (pty_keepalive_table[k].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[k].linux_pts_num != linux_pts_num) - continue; - pty_keepalive_table[k].guest_slave_count++; - pty_keepalive_table[k].guest_slave_seen = true; - pty_diag( - "pty: +slave pts=%u hostfd=%d bump_shared=%d local=%d " - "shared=%d", - linux_pts_num, slave_host_fd, (int) bump_shared, - pty_keepalive_table[k].guest_slave_count, - pty_keepalive_table[k].shared - ? atomic_load(&pty_keepalive_table[k].shared->slave_count) + - (bump_shared ? 1 : 0) - : -1); - if (pty_keepalive_table[k].shared) { - if (bump_shared) - atomic_fetch_add( - &pty_keepalive_table[k].shared->slave_count, 1); - atomic_store(&pty_keepalive_table[k].shared->seen, 1); - } - break; - } - break; - } -} - -static void pty_note_guest_slave(int slave_host_fd, - uint32_t linux_pts_num, - bool bump_shared) -{ - if (slave_host_fd < 0) - return; - pthread_mutex_lock(&pty_keepalive_lock); - pty_guest_slave_table_init_once(); - - /* Drop any entry left over for this host fd number first. The open is - * recorded before the guest fd is installed, so a failed fd_alloc closes - * the host fd without passing through the close hooks; retiring the stale - * slot on reuse keeps that from inflating an unrelated pty's count. - */ - pty_guest_slave_release_locked(slave_host_fd); - pty_guest_slave_record_locked(slave_host_fd, linux_pts_num, bump_shared); - pthread_mutex_unlock(&pty_keepalive_lock); -} - -void proc_pty_note_guest_slave(int slave_host_fd, uint32_t linux_pts_num) -{ - pty_note_guest_slave(slave_host_fd, linux_pts_num, true); -} - -void proc_pty_fork_parent_note_inherited(void) -{ - /* fork duplicates every slave fd the guest holds, so the child's copies are - * live the instant fork returns. Count them here, in the parent, while the - * guest is still inside clone: leaving it to the child's own init loses the - * race against a parent that closes its copy immediately, which is exactly - * what openpty(3)-style terminal startup does. The pty would look hung up - * in that window and the terminal would see its shell die at startup. - */ - pty_keepalive_lock_acquire(); - /* The sentinel init is what makes an unused slot readable as free. Without - * it a table still in its BSS-zero state reads as PTY_GUEST_SLAVE_MAX - * occupied slots holding host fd 0, and every one of them would be counted - * as an inherited slave -- which is what a parent that never opened a slave - * itself does on its very first fork. - */ - pty_guest_slave_table_init_once(); - for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { - if (pty_guest_slave_table[i].slave_host_fd == PTY_KEEPALIVE_FREE) - continue; - uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - if (pty_keepalive_table[k].master_host_fd == PTY_KEEPALIVE_FREE) - continue; - if (pty_keepalive_table[k].linux_pts_num != pts_num) - continue; - if (pty_keepalive_table[k].shared) - atomic_fetch_add(&pty_keepalive_table[k].shared->slave_count, - 1); - break; - } - } - pthread_mutex_unlock(&pty_keepalive_lock); -} - -void proc_pty_dup_guest_slave_locked(int src_slave_host_fd, - int dst_slave_host_fd) -{ - if (src_slave_host_fd < 0 || dst_slave_host_fd < 0) - return; - pty_guest_slave_table_init_once(); - - uint32_t pts_num = UINT32_MAX; - for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { - if (pty_guest_slave_table[i].slave_host_fd == src_slave_host_fd) { - pts_num = pty_guest_slave_table[i].linux_pts_num; - break; - } - } - if (pts_num == UINT32_MAX) - return; /* not a tracked slave; nothing to mirror */ - - /* The dup is a live reference to the same slave, so it has to be counted - * like the open that produced the source. Only open() used to register, so - * a terminal that dup2()s its slave onto stdin/stdout/stderr and closes the - * original left the count at zero with three references still open -- the - * master then reported a hangup with the shell still running. - */ - pty_guest_slave_release_locked(dst_slave_host_fd); - pty_guest_slave_record_locked(dst_slave_host_fd, pts_num, true); -} - -void proc_pty_release_process_slaves(void) -{ - /* Hand back every slave this process still holds, at process teardown. - * - * Per-fd cleanup cannot be relied on for this: a shell exiting normally - * never closes its stdio, the kernel does, so the slaves backing fds 0/1/2 - * leave no close hook behind. Without this the shared count keeps a - * departed shell's slaves forever and the master never reports the hangup - * its terminal is waiting on -- the "window stays open after exit" case. - * - * A process killed outright still cannot run this, and leaks its - * contribution. That is bounded: the host pty is only recycled once every - * fd on it is gone, and the next master to claim that path starts a fresh - * segment (see pty_shared_attach), so the stale count is discarded rather - * than inherited. - */ - pty_keepalive_lock_acquire(); - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (!pty_keepalive_table[i].shared) - continue; - pty_shared_detach(pty_keepalive_table[i].shared, - pty_keepalive_table[i].slave_path, - pty_keepalive_table[i].guest_slave_count); - pty_keepalive_table[i].shared = NULL; - pty_keepalive_table[i].guest_slave_count = 0; - } - pthread_mutex_unlock(&pty_keepalive_lock); -} - -void proc_pty_adopt_inherited_slaves(void) -{ - /* A guest fork hands the child every slave fd the parent had open, but the - * table that maps a host fd back to its pty is per-process and does not - * travel, so those inherited slaves were counted by nobody. The parent then - * closes its own copy -- exactly what openpty(3)-style startup does -- the - * count falls to zero while the child's shell still holds a live slave, and - * the master reports a hangup the instant the terminal window appears. - * - * Recover the mapping from the host instead of shipping more state: a pty - * slave is a char device whose rdev matches the slave path recorded in the - * keepalive entry, which the child has just restored. Runs in the forked - * child's single-threaded init, after the fd table and the keepalives. - */ - struct { - uint32_t pts_num; - dev_t rdev; - int skip_slave_fd; - int skip_master_fd; - } ptys[PTY_KEEPALIVE_MAX]; - int npty = 0; - - pty_keepalive_lock_acquire(); - for (int i = 0; i < PTY_KEEPALIVE_MAX && npty < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE) - continue; - if (pty_keepalive_table[i].slave_path[0] == '\0') - continue; - struct stat st; - if (stat(pty_keepalive_table[i].slave_path, &st) != 0 || - !S_ISCHR(st.st_mode)) - continue; - ptys[npty].pts_num = pty_keepalive_table[i].linux_pts_num; - ptys[npty].rdev = st.st_rdev; - ptys[npty].skip_slave_fd = pty_keepalive_table[i].slave_host_fd; - ptys[npty].skip_master_fd = pty_keepalive_table[i].master_host_fd; - npty++; - } - pthread_mutex_unlock(&pty_keepalive_lock); - if (npty == 0) - return; - - /* Snapshot the host fds before matching: proc_pty_note_guest_slave takes - * pty_keepalive_lock, which sorts before fd_lock, so neither lock can be - * held while calling it. - */ - int host_fds[FD_TABLE_SIZE]; - int nfd = 0; - pthread_mutex_lock(&fd_lock); - for (int gfd = 0; gfd < FD_TABLE_SIZE; gfd++) { - if (fd_table[gfd].type == FD_CLOSED || fd_table[gfd].host_fd < 0) - continue; - host_fds[nfd++] = fd_table[gfd].host_fd; - } - pthread_mutex_unlock(&fd_lock); - - for (int i = 0; i < nfd; i++) { - struct stat st; - if (fstat(host_fds[i], &st) != 0 || !S_ISCHR(st.st_mode)) - continue; - for (int p = 0; p < npty; p++) { - if (st.st_rdev != ptys[p].rdev) - continue; - /* elfuse's own keepalive slave is not a guest slave, and the - * master never matches the slave's rdev but is cheap to exclude. - */ - if (host_fds[i] == ptys[p].skip_slave_fd || - host_fds[i] == ptys[p].skip_master_fd) - break; - /* Local books only: the parent already counted these copies into - * the shared total before fork returned. - */ - pty_note_guest_slave(host_fds[i], ptys[p].pts_num, false); - break; - } - } -} - -void proc_pty_slave_fd_closed(int host_fd) -{ - if (host_fd < 0) - return; - pthread_mutex_lock(&pty_keepalive_lock); - pty_guest_slave_table_init_once(); - pty_guest_slave_release_locked(host_fd); - pthread_mutex_unlock(&pty_keepalive_lock); -} - -/* Whether this slot's pty has no guest slave left. Reads the shared segment - * when one is mapped, so a slave held by another process in the fork family - * counts; falls back to the per-process view when it is not. Caller holds - * pty_keepalive_lock. - */ -static bool pty_slot_hung_up_locked(int slot) -{ - pty_shared_t *sh = pty_keepalive_table[slot].shared; - bool hung_up; - if (sh) - hung_up = - atomic_load(&sh->seen) != 0 && atomic_load(&sh->slave_count) <= 0; - else - hung_up = pty_keepalive_table[slot].guest_slave_seen && - pty_keepalive_table[slot].guest_slave_count == 0; - - /* Only on the way to reporting one: the negative answer is the steady - * state and every poll would log it. This subsystem spans processes, so - * without a record of which side saw what a wrong verdict is very hard to - * place after the fact. - */ - if (hung_up) - pty_diag("pty: HANGUP pts=%u seen=%d shared=%d local=%d/%d path=%s", - pty_keepalive_table[slot].linux_pts_num, - sh ? atomic_load(&sh->seen) : -1, - sh ? atomic_load(&sh->slave_count) : -1, - (int) pty_keepalive_table[slot].guest_slave_seen, - pty_keepalive_table[slot].guest_slave_count, - pty_keepalive_table[slot].slave_path); - return hung_up; -} - -bool proc_pty_master_hung_up(int guest_fd, uint64_t expect_generation) -{ - /* Keyed on the guest fd rather than a host one: callers reach the master - * through host_fd_ref, which hands out a dup, and the keepalive table is - * keyed by the canonical host fd that dup does not share. - */ - fd_entry_t snap; - if (!fd_snapshot(guest_fd, &snap)) - return false; - /* The caller resolved this guest fd earlier; re-resolving it here reopens - * the close-and-reuse window. Reject a slot that has been recycled since, - * so the hangup is never charged to an unrelated file. - */ - if (snap.generation != expect_generation) - return false; - int master_host_fd = snap.host_fd; - if (master_host_fd < 0) - return false; - bool hung_up = false; - pthread_mutex_lock(&pty_keepalive_lock); - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd != master_host_fd) - continue; - hung_up = pty_slot_hung_up_locked(i); - break; - } - pthread_mutex_unlock(&pty_keepalive_lock); - return hung_up; -} - -static int pty_open_slave(uint32_t linux_pts_num, int linux_flags) -{ - int oflags = translate_open_flags(linux_flags) & - (O_ACCMODE | O_NONBLOCK | O_CLOEXEC | O_NOCTTY); - char host_path[PTY_SLAVE_PATH_MAX]; - int stale_hit = -1; - int retained_slaves[PTY_KEEPALIVE_MAX]; - int nretained = 0; - int fd; - - pty_keepalive_lock_acquire(); - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].linux_pts_num != linux_pts_num) - continue; - if (pty_keepalive_table[i].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) { - size_t len = strlen(pty_keepalive_table[i].slave_path); - if (len >= sizeof(host_path)) { - pthread_mutex_unlock(&pty_keepalive_lock); - errno = ENAMETOOLONG; - return -1; - } - memcpy(host_path, pty_keepalive_table[i].slave_path, len + 1); - pthread_mutex_unlock(&pty_keepalive_lock); - return open(host_path, oflags); - } - if (stale_hit < 0 && pty_keepalive_table[i].stale_open_once && - pty_keepalive_table[i].slave_host_fd >= 0) - stale_hit = i; - } - - if (stale_hit < 0) { - pthread_mutex_unlock(&pty_keepalive_lock); - errno = ENOENT; - return -1; - } - - /* Stale fork-child entries are one-shot. The retained slave fd pins the - * macOS tty while we translate the close-before-open sequence, preventing - * the cached path from resolving to a reused unrelated minor. Regardless of - * open success, consume the stale mapping before returning. - */ - size_t len = strlen(pty_keepalive_table[stale_hit].slave_path); - if (len >= sizeof(host_path)) { - int retained_slave = pty_keepalive_retire_stale_locked(stale_hit); - pthread_mutex_unlock(&pty_keepalive_lock); - if (retained_slave >= 0) - close(retained_slave); - errno = ENAMETOOLONG; - return -1; - } - memcpy(host_path, pty_keepalive_table[stale_hit].slave_path, len + 1); - fd = open(host_path, oflags); - int saved = errno; - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) - continue; - if (!pty_keepalive_table[i].stale_open_once) - continue; - if (strncmp(pty_keepalive_table[i].slave_path, host_path, - PTY_SLAVE_PATH_MAX) != 0) - continue; - int retained_slave = pty_keepalive_retire_stale_locked(i); - if (retained_slave >= 0 && nretained < PTY_KEEPALIVE_MAX) - retained_slaves[nretained++] = retained_slave; - } - pthread_mutex_unlock(&pty_keepalive_lock); - for (int i = 0; i < nretained; i++) - close(retained_slaves[i]); - errno = saved; - return fd; -} - -static int pty_open_pts_dir(int linux_flags) -{ - char dir[80]; - uint32_t pts_nums[PTY_KEEPALIVE_MAX]; - int pts_count = 0; - int n = snprintf(dir, sizeof(dir), "/tmp/elfuse-pts-XXXXXX"); - if (n < 0 || (size_t) n >= sizeof(dir)) { - errno = ENAMETOOLONG; - return -1; - } - if (!mkdtemp(dir)) - return -1; - - pty_keepalive_lock_acquire(); - - /* Enumerate live masters and fork-child one-shot stale entries. The stale - * entries retain a slave fd until the first open attempt consumes them, so - * they cannot name a reused unrelated tty while they appear in readdir. - */ - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE && - (!pty_keepalive_table[i].stale_open_once || - pty_keepalive_table[i].slave_host_fd < 0)) - continue; - - /* The recycle/reuse-by-pts_num invariant in - * pty_keepalive_register_locked keeps at most one entry per minor, so - * no de-duplication pass is needed here. - */ - pts_nums[pts_count++] = pty_keepalive_table[i].linux_pts_num; - } - pthread_mutex_unlock(&pty_keepalive_lock); - - for (int i = 0; i < pts_count; i++) { - char entry[160]; - int en = snprintf(entry, sizeof(entry), "%s/%u", dir, pts_nums[i]); - if (en <= 0 || (size_t) en >= sizeof(entry)) - continue; - int tfd = open(entry, O_CREAT | O_WRONLY, 0444); - if (tfd >= 0) - close(tfd); - } - - pthread_once(&proc_scratch_atexit_once, proc_scratch_register_atexit); - - pthread_mutex_lock(&proc_scratch_lock); - if (proc_scratch_dirs_count < PROC_SCRATCH_DIRS_MAX) { - str_copy_trunc(proc_scratch_dirs[proc_scratch_dirs_count++], dir, - sizeof(proc_scratch_dirs[0])); - } - pthread_mutex_unlock(&proc_scratch_lock); - - int fd = proc_open_dir_fd(dir, linux_flags); - if (fd < 0) { - int saved = errno; - proc_scratch_remove_one(dir); - errno = saved; - } - return fd; -} - -void proc_pty_lock_for_dup(void) -{ - pty_keepalive_lock_acquire(); -} - -void proc_pty_unlock_for_dup(void) -{ - pthread_mutex_unlock(&pty_keepalive_lock); -} - -void proc_pty_dup_keepalive_locked(int src_master_host_fd, - int dst_master_host_fd) -{ - /* Caller-holds-lock variant; see header for the dup race this guards. */ - if (src_master_host_fd < 0 || dst_master_host_fd < 0) - return; - - int slot = pty_keepalive_find_master_locked(src_master_host_fd); - if (slot < 0) - return; - int dst_slave = dup(pty_keepalive_table[slot].slave_host_fd); - if (dst_slave < 0) - return; - uint32_t src_pts_num = pty_keepalive_table[slot].linux_pts_num; - char src_slave_path[PTY_SLAVE_PATH_MAX]; - memcpy(src_slave_path, pty_keepalive_table[slot].slave_path, - PTY_SLAVE_PATH_MAX); - - /* dup(2) clears FD_CLOEXEC; the keepalive must not survive exec into a - * guest child that has no map back to it. - */ - if (fd_set_cloexec(dst_slave) < 0) { - close(dst_slave); - return; - } - int rc = pty_keepalive_register_locked(dst_master_host_fd, dst_slave, - src_pts_num, src_slave_path, false, - /*fresh_segment=*/false, NULL); - if (rc != PTY_REG_INSERTED) { - /* Table full or duplicate entry for dst_master_host_fd; drop the - * redundant slave. Duplicate is unexpected: dst is a freshly-duped host - * fd that should not already be in the table unless a prior close - * skipped proc_pty_close_keepalive. - */ - close(dst_slave); - } -} - -void proc_pty_close_keepalive(int master_host_fd) -{ - /* fd_cleanup_entry calls this for every guest fd close, not just pty - * masters; pty_keepalive_lock_acquire guarantees sentinel-init first. - */ - if (master_host_fd < 0) - return; - - int slave = -1; - pty_keepalive_lock_acquire(); - int slot = pty_keepalive_find_master_locked(master_host_fd); - if (slot >= 0) { - if (pty_keepalive_table[slot].stale_open_once) { - /* Fork-restored child entry: retain the slave fd and path for one - * /dev/pts/N open after close(master). pty_open_slave consumes and - * closes it on the first translated open attempt. - */ - /* Only the master goes away here. Any slave fd this process still - * holds stays open and keeps counting: closing the master does not - * close the slaves, and a terminal's child routinely drops its copy - * of the master while holding the slave as its stdio. Retiring the - * count here would report a hangup with the shell still running. - * The slaves decrement themselves as they close. - */ - pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; - } else { - slave = pty_keepalive_clear_slot_locked(slot); - } - } - pthread_mutex_unlock(&pty_keepalive_lock); - if (slave >= 0) - close(slave); -} - -static void proc_pty_expire_stale_by_path(const char *slave_path) -{ - if (!slave_path || slave_path[0] == '\0') - return; - - int stale_slaves[PTY_KEEPALIVE_MAX]; - int nslaves = 0; - pty_keepalive_lock_acquire(); - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { - if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) - continue; - if (!pty_keepalive_table[i].stale_open_once) - continue; - if (strncmp(pty_keepalive_table[i].slave_path, slave_path, - PTY_SLAVE_PATH_MAX) != 0) - continue; - int slave = pty_keepalive_clear_slot_locked(i); - if (slave >= 0 && nslaves < PTY_KEEPALIVE_MAX) - stale_slaves[nslaves++] = slave; - } - pthread_mutex_unlock(&pty_keepalive_lock); - for (int i = 0; i < nslaves; i++) - close(stale_slaves[i]); -} - -static int pty_keepalive_register_recycled(int master_host_fd, - int slave_host_fd, - uint32_t linux_pts_num, - const char *slave_path, - bool stale_open_once, - bool fresh_segment) -{ - proc_pty_expire_stale_by_path(slave_path); - return pty_keepalive_register(master_host_fd, slave_host_fd, linux_pts_num, - slave_path, stale_open_once, fresh_segment); -} - -int proc_pty_snapshot_keepalive(proc_pty_ipc_entry_t *out_entries, - int *out_slave_fds, - int max_entries) -{ - if (!out_entries || !out_slave_fds || max_entries <= 0) - return 0; - - int n = 0; - pty_keepalive_lock_acquire(); - for (int i = 0; i < PTY_KEEPALIVE_MAX && n < max_entries; i++) { - if (pty_keepalive_table[i].master_host_fd == PTY_KEEPALIVE_FREE) - continue; - - /* dup under the lock so the slave fd cannot be closed and the host fd - * number recycled before SCM_RIGHTS reads it. The caller closes the dup - * after the send completes. - */ - int duped = dup(pty_keepalive_table[i].slave_host_fd); - if (duped < 0) - continue; - - out_entries[n].master_host_fd = pty_keepalive_table[i].master_host_fd; - out_entries[n].linux_pts_num = pty_keepalive_table[i].linux_pts_num; - _Static_assert(sizeof(out_entries[n].slave_path) == PTY_SLAVE_PATH_MAX, - "ipc slave_path size must match keepalive table"); - memcpy(out_entries[n].slave_path, pty_keepalive_table[i].slave_path, - PTY_SLAVE_PATH_MAX); - out_slave_fds[n] = duped; - n++; - } - pthread_mutex_unlock(&pty_keepalive_lock); - return n; -} - -void proc_pty_restore_keepalive(int master_host_fd, - int slave_host_fd, - uint32_t linux_pts_num, - const char *slave_path) -{ - /* fork-IPC hand-off. SCM_RIGHTS drops FD_CLOEXEC; set it here so the - * keepalive does not survive exec. Any failure drops the slave fd. - */ - if (master_host_fd < 0) - goto drop; - - if (slave_host_fd >= 0 && fd_set_cloexec(slave_host_fd) < 0) - goto drop; - - /* Trust the parent's linux_pts_num verbatim instead of re-parsing - * slave_path. The wire-format string is bounded to PTY_SLAVE_PATH_MAX - 1 - * bytes; if a future macOS canonical form ever exceeded that, the parent - * would have truncated and reparsing here would yield the wrong number. On - * EEXIST the child's fd_table-restore path replayed master_host_fd over a - * prior recv-keepalive entry; drop the redundant slave so it does not leak. - */ - errno = 0; - if (pty_keepalive_register_recycled(master_host_fd, slave_host_fd, - linux_pts_num, slave_path, true, - /*fresh_segment=*/false) < 0 || - errno == EEXIST) - goto drop; - return; - -drop: - if (slave_host_fd >= 0) - close(slave_host_fd); -} - -/* Open /dev/ptmx, unlock the slave, and instantiate a keepalive slave fd so the - * master's tty ioctls work before the guest opens the slave itself. - * Returns the master host fd on success, -1 with errno set on failure. - */ -static int pty_open_master(int linux_flags) -{ - /* /dev/ptmx is a character device; O_CREAT / O_TRUNC / O_EXCL make no sense - * here. Strip them and only honor accmode + descriptor flags so the host - * open(2) never sees a variadic-mode-required combination without a mode - * arg. - */ - int oflags = translate_open_flags(linux_flags) & - (O_ACCMODE | O_NONBLOCK | O_CLOEXEC | O_NOCTTY); - int master = open("/dev/ptmx", oflags); - if (master < 0) - return -1; - - /* grantpt(3) is a no-op on a unix98 pty mount, but call it for clarity and - * to match what posix_openpt(3)'s callers expect to have happened. - */ - char slave_path[PTY_SLAVE_PATH_MAX]; - if (grantpt(master) < 0 || unlockpt(master) < 0 || - ptsname_r(master, slave_path, sizeof(slave_path)) != 0) { - close_keep_errno(master); - return -1; - } - - /* Establish the (linux_pts_num, slave_path) mapping that /dev/pts/N opens - * and stats resolve through. If table or slave-fd registration fails after - * the master is open, report EMFILE rather than silently returning a master - * fd whose pts number cannot be opened back through /dev/pts/N. The caller - * can close other pty pairs and retry instead of dealing with a half-broken - * descriptor. - */ - uint32_t linux_pts_num = pty_extract_pts_num(slave_path); - if (linux_pts_num == UINT32_MAX) { - close(master); - errno = ENOTTY; - return -1; - } - int slave = open(slave_path, O_RDWR | O_NOCTTY | O_CLOEXEC); - if (slave < 0) { - close_keep_errno(master); - return -1; - } - errno = 0; - /* The host just allocated this pty, so nothing live can be using a segment - * under its name; any leftover is state a process died holding. - */ - if (pty_keepalive_register_recycled(master, slave, linux_pts_num, - slave_path, false, - /*fresh_segment=*/true) < 0) { - close(slave); - close(master); - errno = EMFILE; - return -1; - } - - /* Defense-in-depth: the freshly-opened master fd should not already have a - * keepalive (would indicate a stale entry from a prior close that did not - * run proc_pty_close_keepalive). Drop the redundant slave so it does not - * leak. - */ - if (errno == EEXIST) - close(slave); - return master; -} /* Build the VMA list shared by /proc/self/maps and /proc/self/smaps. Merges * contiguous regions[] runs that came from one mmap, then folds in the @@ -3647,6 +2167,13 @@ static int proc_open_self_task_node(const guest_t *g, return PROC_NOT_INTERCEPTED; /* unknown /proc/self/task//XXX */ } +/* Group that owns pty slaves. Linux distributions mount devpts with gid=5 + * ("tty") and glibc's grantpt(3) looks that group up before deciding whether + * the slave needs chowning. Only this file reports it, so it lives here rather + * than in the shared header. + */ +#define PTY_SLAVE_TTY_GID 5u + /* Handle the mount-table /proc nodes: /proc/filesystems, /proc/self/mountinfo, * and /proc/{mounts,self/mounts} plus /etc/mtab. * @@ -4927,7 +3454,11 @@ int proc_intercept_read(int guest_fd, */ char text[32]; int len = proc_oom_format_value(kind, text, sizeof(text)); - return proc_oom_copy_slice(buf, count, offset, text, (size_t) len, + if (len < 0) { + errno = EIO; + return -1; + } + return proc_oom_copy_slice(buf, count, offset, text, (uint64_t) len, read_out); } @@ -4951,6 +3482,10 @@ int proc_intercept_readv(int guest_fd, char text[32]; int len = proc_oom_format_value(kind, text, sizeof(text)); + if (len < 0) { + errno = EIO; + return -1; + } size_t src_len = (size_t) len; if ((uint64_t) offset >= src_len) { *read_out = 0; From 36ee97eded6a6ef0267e09955550d24a2d8f35eb Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:29:33 +0800 Subject: [PATCH 02/15] Extract five HVC cases The loop every syscall passes through ran to 1178 lines, which makes it simultaneously the riskiest function in the tree to modify and the least readable. Five of its cases handle an exception and are otherwise self-contained: the MRS trap, the system-instruction trap, the W^X toggle, BRK from EL0, and the bad-exception halt. --- src/syscall/proc.c | 865 +++++++++++++++++++++++---------------------- 1 file changed, 438 insertions(+), 427 deletions(-) diff --git a/src/syscall/proc.c b/src/syscall/proc.c index d8106ac9..fc20246a 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -1049,9 +1049,9 @@ static void proc_register_adopted_local(const lifecycle_entry_t *source) if (entry && entry->host_waitable) { /* proc_process_exit() publishes the status and raises SIGCHLD while the * host process is still tearing down, so wait4 reports "running" for a - * moment after the guest was told the child is gone, a skew Linux - * never has. Copy the status across so a WNOHANG poll from the handler - * cannot miss it; proc_deferred_reap_poll() does the host reap later so + * moment after the guest was told the child is gone, a skew Linux never + * has. Copy the status across so a WNOHANG poll from the handler cannot + * miss it; proc_deferred_reap_poll() does the host reap later so * nothing blocks here. */ if (source->exited && !entry->exited) { @@ -3134,6 +3134,433 @@ static const hv_sys_reg_t hvc4_sysregs[] = { HV_SYS_REG_TTBR1_EL1, /* 8 */ }; +/* HVC #7: MRS trap emulation. Guest EL0 code read a system register; extract + * the encoding from ESR_EL1's ISS field, read it via HVF, and leave the value + * in X0 for the shim to store into the saved register frame. + * + * Lifted out of vcpu_run_loop_with_hooks, which every syscall passes through + * and which had grown past a thousand lines. This case cannot end the loop and + * reads no loop state beyond the vCPU handle and the two logging arguments, so + * it moves whole. + */ +static void vcpu_handle_mrs_trap(hv_vcpu_t vcpu, + bool verbose, + const char *prefix) +{ + uint64_t esr; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); + uint32_t iss = (uint32_t) (esr & 0x1FFFFFF); + + /* ISS encoding for EC=0x18 (MSR/MRS trap): + * [21:20] = Op0 [19:17] = Op2 + * [16:14] = Op1 [13:10] = CRn + * [9:5] = Rt [4:1] = CRm + * [0] = Direction (1=MRS read) + */ + uint32_t op0 = (iss >> 20) & 0x3, op2 = (iss >> 17) & 0x7; + uint32_t op1 = (iss >> 14) & 0x7, crn = (iss >> 10) & 0xF; + uint32_t crm = (iss >> 1) & 0xF; + + /* Construct HVF system register ID: + * (Op0<<14) | (Op1<<11) | (CRn<<7) | (CRm<<3) | Op2 + */ + hv_sys_reg_t reg = (hv_sys_reg_t) ((op0 << 14) | (op1 << 11) | (crn << 7) | + (crm << 3) | op2); + + uint64_t value = 0; + + /* ID register emulation: return VZ-sanitized values matching a real VZ + * (Lima) VM BEFORE trying HVF. HVF's hv_vcpu_get_sys_reg succeeds for ID + * registers but returns raw hardware values, which include features the + * hypervisor does not actually virtualize. + * + * Values captured from a Lima VZ VM on Apple Silicon via inline MRS from + * EL0 (kernel trap-and-emulate). These are checked first, before the HVF + * call. + */ + bool have_vz_override = false; + + /* ID_AA64MMFR0_EL1 (3,0,0,7,0) */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && op2 == 0) { + value = 0x00000111ff000000ULL; + have_vz_override = true; + } + + /* ID_AA64MMFR1_EL1 (3,0,0,7,1): VZ returns 0. Raw hardware (e.g., + * 0x11212000) exposes HPDS, PAN, LO, XNX etc. that VZ does not virtualize. + */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && op2 == 1) { + value = 0x0000000000000000ULL; + have_vz_override = true; + } + /* ID_AA64MMFR2_EL1 (3,0,0,7,2): VZ returns 0. */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && op2 == 2) { + value = 0x0000000000000000ULL; + have_vz_override = true; + } + /* ID_AA64ISAR0_EL1 (3,0,0,6,0) */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 6 && op2 == 0) { + value = 0x0021100110212120ULL; + have_vz_override = true; + } + /* ID_AA64ISAR1_EL1 (3,0,0,6,1) */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 6 && op2 == 1) { + value = 0x0000101110211402ULL; + have_vz_override = true; + } + /* ID_AA64PFR0_EL1 (3,0,0,4,0) */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 4 && op2 == 0) { + value = 0x0001000000110011ULL; + have_vz_override = true; + } + /* ID_AA64PFR1_EL1 (3,0,0,4,1): VZ returns 0. */ + if (op0 == 3 && op1 == 0 && crn == 0 && crm == 4 && op2 == 1) { + value = 0x0000000000000000ULL; + have_vz_override = true; + } + + if (have_vz_override) { + if (verbose) + log_debug( + "%s: MRS trap: Op0=%u Op1=%u " + "CRn=%u CRm=%u Op2=%u -> 0x%llx (VZ)", + prefix, op0, op1, crn, crm, op2, (unsigned long long) value); + } + + hv_return_t ret = + have_vz_override ? HV_SUCCESS : hv_vcpu_get_sys_reg(vcpu, reg, &value); + if (ret != HV_SUCCESS) { + /* HVF does not expose this register. Provide a host-side fallback for + * known registers. + */ + bool have_fallback = false; + + /* CNTFRQ_EL0 (3,3,14,0,0): counter frequency. Read directly from host + * hardware (Apple Silicon uses 24MHz). + */ + if (op0 == 3 && op1 == 3 && crn == 14 && crm == 0 && op2 == 0) { + __asm__ volatile("mrs %0, cntfrq_el0" : "=r"(value)); + have_fallback = true; + } + + /* Non-ID register fallbacks for registers that HVF does not expose. ID + * registers are handled above (VZ overrides). + */ + + if (verbose) { + if (have_fallback) { + log_debug( + "%s: MRS trap: " + "Op0=%u Op1=%u CRn=%u CRm=%u " + "Op2=%u -> 0x%llx (host)", + prefix, op0, op1, crn, crm, op2, + (unsigned long long) value); + } else { + log_debug( + "%s: MRS trap: unknown reg " + "Op0=%u Op1=%u CRn=%u CRm=%u " + "Op2=%u (hv_reg=0x%x) -> 0", + prefix, op0, op1, crn, crm, op2, (unsigned) reg); + } + } + } else if (verbose) { + log_debug( + "%s: MRS trap: Op0=%u Op1=%u " + "CRn=%u CRm=%u Op2=%u -> 0x%llx", + prefix, op0, op1, crn, crm, op2, (unsigned long long) value); + } + + hv_vcpu_set_reg(vcpu, HV_REG_X0, value); +} + +/* HVC #12: system instruction trap. The guest executed a cache maintenance + * instruction HVF traps; log it and step past. Lifted out of + * vcpu_run_loop_with_hooks with the other self-contained cases: it reads no + * loop state and cannot end the loop. + */ +static void vcpu_handle_sysinstr_trap(hv_vcpu_t vcpu, + bool verbose, + const char *prefix) +{ + /* HVC #12: System instruction trap (EC=0x18 Direction=0). The shim forwards + * trapped cache maintenance instructions (DC CVAU, IC IVAU, etc.) here for + * logging/counting. It also passes the original Rt value in X0 so host-side + * emulation can handle MSR writes such as TPIDR_EL0. The shim has already + * advanced PC and will restore X0 from its saved frame before returning to + * EL0. + */ + atomic_fetch_add(&sysreg_write_count, 1); + uint64_t rt_value = 0; + hv_vcpu_get_reg(vcpu, HV_REG_X0, &rt_value); + uint64_t esr; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); + uint32_t iss = (uint32_t) (esr & 0x1FFFFFF); + + /* Decode ISS for system instruction: + * Op0[21:20] Op2[19:17] Op1[16:14] + * CRn[13:10] Rt[9:5] CRm[4:1] Dir[0] + */ + uint32_t op0 = (iss >> 20) & 0x3, op2 = (iss >> 17) & 0x7; + uint32_t op1 = (iss >> 14) & 0x7, crn = (iss >> 10) & 0xF; + uint32_t crm = (iss >> 1) & 0xF, rt = (iss >> 5) & 0x1F; + + /* TPIDR_EL0 (S3_3_C13_C0_2): userspace TLS base. Static glibc writes this + * during early startup. HVF traps the MSR, so Linux-compatible execution + * requires reflecting the write into the virtual sysreg. + */ + if (op0 == 3 && op1 == 3 && crn == 13 && crm == 0 && op2 == 2) { + HV_CHECK(hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_TPIDR_EL0, rt_value)); + } + if (verbose) { + /* DC CVAU: Op0=1,Op1=3,CRn=7,CRm=11,Op2=1 IC IVAU: + * Op0=1,Op1=3,CRn=7,CRm=5,Op2=1 + */ + const char *name = "unknown"; + if (op0 == 1 && op1 == 3 && crn == 7 && crm == 11 && op2 == 1) + name = "DC CVAU"; + else if (op0 == 1 && op1 == 3 && crn == 7 && crm == 5 && op2 == 1) + name = "IC IVAU"; + else if (op0 == 1 && op1 == 3 && crn == 7 && crm == 10 && op2 == 1) + name = "DC CVAC"; + else if (op0 == 1 && op1 == 3 && crn == 7 && crm == 14 && op2 == 1) + name = "DC CIVAC"; + else if (op0 == 3 && op1 == 3 && crn == 13 && crm == 0 && op2 == 2) + name = "MSR TPIDR_EL0"; + log_debug( + "%s: sysreg trap #%llu: %s " + "(Op0=%u Op1=%u CRn=%u CRm=%u Op2=%u " + "Rt=X%u val=0x%llx)", + prefix, (unsigned long long) atomic_load(&sysreg_write_count), name, + op0, op1, crn, crm, op2, rt, (unsigned long long) rt_value); + } +} + +/* HVC #9: W^X toggle. HVF enforces W^X on stage-2, so a guest page that must + * become executable is flipped RW -> RX here (and back on the first write). + * Returns false when the fault cannot be served and the vCPU must stop. + */ +static bool vcpu_handle_wx_toggle(guest_t *g, + hv_vcpu_t vcpu, + bool verbose, + const char *prefix, + int *exit_code) +{ + /* HVC #9: W^X page permission toggle for JIT. + * + * Apple HVF enforces W^X: pages cannot be both writable and executable + * simultaneously. JIT code needs to be written (RW), then executed (RX). + * The shim detects permission faults (EC=0x20 instruction abort, EC=0x24 + * data abort) and forwards the faulting address here. + * + * Toggling at 2MiB granularity causes thrashing when the JIT writes new + * code and executes existing code within the same 2MiB block. Instead, the + * code splits the 2MiB block into 4KiB L3 pages and toggle only the + * faulting 4KiB page. This allows different pages within a 2MiB block to + * have independent RW/RX permissions simultaneously. + * + * x0 = FAR_EL1 (faulting virtual address) x1 = type: 0 = exec fault -> flip + * to RX + * 1 = write fault -> flip to RW + */ + uint64_t far, type; + hv_vcpu_get_reg(vcpu, HV_REG_X0, &far); + hv_vcpu_get_reg(vcpu, HV_REG_X1, &type); + + uint64_t page_start = far & ~(4096ULL - 1); + uint64_t page_end = page_start + 4096; + int new_perms = (type == 0) ? MEM_PERM_RX : MEM_PERM_RW; + + /* Hold mmap_lock for page table modifications AND region lookups to prevent + * races with concurrent mmap/mprotect/munmap from other vCPU threads. + */ + pthread_mutex_lock(&mmap_lock); + + /* Check if this is a genuine permission violation (not a W^X toggle). If + * the guest region lacks the required permission, deliver SIGSEGV instead + * of toggling. This handles mprotect(PROT_READ), SHM_RDONLY, PROT_NONE, and + * non-exec pages. + */ + { + uint64_t off = far - g->ipa_base; + const guest_region_t *reg = guest_region_find(g, off); + int required = (type == 1) ? LINUX_PROT_WRITE : LINUX_PROT_EXEC; + if (reg && !(reg->prot & required)) { + pthread_mutex_unlock(&mmap_lock); + uint64_t esr; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); + signal_set_fault_info(LINUX_SEGV_ACCERR, far, esr); + int sig_ret = + signal_deliver_fault(vcpu, g, LINUX_SIGSEGV, exit_code); + if (sig_ret < 0) + return false; + /* Fault delivered; the loop keeps running. */ + return true; + } + } + + /* Count W^X toggles for JIT debugging */ + if (type == 0) + atomic_fetch_add(&wxcount_to_rx, 1); + else + atomic_fetch_add(&wxcount_to_rw, 1); + + if (verbose) + log_debug("%s: W^X toggle at 0x%llx -> %s (page 0x%llx)", prefix, + (unsigned long long) far, (type == 0) ? "RX" : "RW", + (unsigned long long) page_start); + uint64_t block_start = far & ~(BLOCK_2MIB - 1); + int sr = guest_split_block(g, block_start); + int ur = guest_update_perms(g, page_start, page_end, new_perms); + pthread_mutex_unlock(&mmap_lock); + if (verbose && (sr < 0 || ur < 0)) + log_warn( + "%s: W^X toggle FAILED " + "(split=%d update=%d) far=0x%llx", + prefix, sr, ur, (unsigned long long) far); + + /* TLB flush is done by the shim (tlbi_restore_eret) for the single faulting + * page. Clear this thread's pending request so the next syscall epilogue + * does not re-flush the W^X page. cpu_tlbi_req is per-vCPU, so this only + * touches our own slot -- concurrent vCPUs are unaffected. + * + * The HVC #9 shim now consumes X8 as a post-HVC marker: 0 means W^X + * succeeded and the shim should run the TLBI retry epilogue; 2 means + * signal_deliver_fault installed a handler frame and the shim must drop its + * saved frame. Clear X8 here so a guest's pre-fault X8 value cannot be + * misread as the frame-drop marker after a normal toggle. + */ + tlbi_request_clear(); + hv_vcpu_set_reg(vcpu, HV_REG_X8, 0); + return true; +} + +/* HVC #10: BRK from EL0. A guest breakpoint becomes a ptrace-stop when the + * thread is traced, and SIGTRAP otherwise. + * + * Returns false when the vCPU must stop. + */ +static bool vcpu_handle_brk(guest_t *g, + hv_vcpu_t vcpu, + bool verbose, + const char *prefix, + int *exit_code) +{ + /* HVC #10: BRK from EL0 -> deliver SIGTRAP or ptrace-stop. + * + * If the thread is ptraced, the BRK enters a ptrace-stop (the tracer + * reads/writes registers then CONT's). Otherwise the run loop queues + * SIGTRAP and delivers it via the signal frame mechanism. + * + * The shim has already restored all GPRs to their EL0 values, so + * signal_deliver / ptrace_stop read correct state. + * + * The Linux kernel sets si_code=TRAP_BRKPT, si_addr=BRK_PC, and + * fault_address=BRK_PC for BRK-triggered SIGTRAP. + */ + uint64_t brk_pc; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ELR_EL1, &brk_pc); + + if (verbose) { + log_debug("%s: BRK at 0x%llx -> %s", prefix, + (unsigned long long) brk_pc, + current_thread->ptraced ? "ptrace-stop" : "SIGTRAP"); + } + + if (current_thread->ptraced) { + /* Ptrace-stop: suspend vCPU, notify tracer. thread_ptrace_stop blocks + * until tracer CONT's. + */ + int cont_sig = thread_ptrace_stop(current_thread, 5); + if (cont_sig > 0) { + signal_queue(cont_sig); + int sr = signal_deliver(vcpu, g, exit_code); + if (sr < 0) + return false; + } + } else { + /* Non-ptraced: deliver SIGTRAP via signal frame. Read ESR_EL1 to + * include in sigcontext. + */ + uint64_t brk_esr; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &brk_esr); + signal_set_fault_info(LINUX_TRAP_BRKPT, brk_pc, brk_esr); + if (verbose) { + uint64_t thread_blocked = + current_thread ? current_thread->blocked : 0xDEAD; + log_debug( + "%s: BRK: thread_blocked=0x%llx " + "pending=0x%llx", + prefix, (unsigned long long) thread_blocked, + (unsigned long long) signal_get_state()->shared.pending); + } + int sig_ret = signal_deliver_fault(vcpu, g, LINUX_SIGTRAP, exit_code); + if (verbose) + log_debug("%s: signal_deliver returned %d", prefix, sig_ret); + if (sig_ret < 0) { + /* SIG_DFL for SIGTRAP: terminate */ + return false; + } + } + return true; +} + +/* HVC #2: bad exception from the shim's vector table. Dumps the guest state and + * stops the vCPU. Its inner continue binds to the register-dump for loop, not + * the enclosing run loop, so this lifts like cases 9 and 10. + */ +static bool vcpu_handle_bad_exception(guest_t *g, + hv_vcpu_t vcpu, + const char *prefix, + int *exit_code) +{ + /* HVC #2: Bad exception in guest. Shim clobbers X0-X3,X5 with exception + * info. X4,X6-X30 and SP_EL0 still hold faulting values. + */ + uint64_t x0, x1, x2, x3, x5; + hv_vcpu_get_reg(vcpu, HV_REG_X0, &x0); + hv_vcpu_get_reg(vcpu, HV_REG_X1, &x1); + hv_vcpu_get_reg(vcpu, HV_REG_X2, &x2); + hv_vcpu_get_reg(vcpu, HV_REG_X3, &x3); + hv_vcpu_get_reg(vcpu, HV_REG_X5, &x5); + log_error( + "%s: guest exception vec=0x%03llx " + "ESR=0x%llx FAR=0x%llx ELR=0x%llx SPSR=0x%llx", + prefix, (unsigned long long) x5, (unsigned long long) x0, + (unsigned long long) x1, (unsigned long long) x2, + (unsigned long long) x3); + + /* Dump preserved registers for debugging */ + uint64_t sp_el0; + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_SP_EL0, &sp_el0); + log_error("%s: SP_EL0=0x%llx", prefix, (unsigned long long) sp_el0); + for (int ri = 4; ri <= 30; ri++) { + /* Skip X5 (clobbered by shim for vec offset) */ + if (ri == 5) + continue; + uint64_t rv; + hv_vcpu_get_reg(vcpu, (hv_reg_t) (HV_REG_X0 + ri), &rv); + log_error("%s: X%-2d=0x%016llx", prefix, ri, (unsigned long long) rv); + } + + /* Check if FAR looks like a tagged pointer */ + uint64_t far = x1; + uint16_t top16 = (uint16_t) (far >> 48); + if (top16 != 0x0000 && top16 != 0xFFFF) { + log_error("%s: FAR tag=0x%04x, extracted addr=0x%llx", prefix, top16, + (unsigned long long) (far & 0x0000FFFFFFFFFFFFULL)); + } + + { + char detail[128]; + snprintf(detail, sizeof(detail), "vec=0x%03llx ESR=0x%llx FAR=0x%llx", + (unsigned long long) x5, (unsigned long long) x0, + (unsigned long long) x1); + crash_report(vcpu, g, CRASH_BAD_EXCEPTION, detail); + } + *exit_code = 128; + return false; +} + /* Unified vCPU execution loop for both main and worker threads. * * When timeout_sec > 0 (main thread): uses alarm() for per-iteration safety @@ -3368,322 +3795,19 @@ int vcpu_run_loop_with_hooks(hv_vcpu_t vcpu, } case 7: { - /* HVC #7: MRS trap emulation. Guest EL0 code read a system - * register. Extract the register encoding from ESR_EL1's - * ISS field and read it via HVF. - * - * Return value in X0 for the shim to store into the saved - * register frame. - */ - uint64_t esr; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); - uint32_t iss = (uint32_t) (esr & 0x1FFFFFF); - - /* ISS encoding for EC=0x18 (MSR/MRS trap): - * [21:20] = Op0 [19:17] = Op2 - * [16:14] = Op1 [13:10] = CRn - * [9:5] = Rt [4:1] = CRm - * [0] = Direction (1=MRS read) - */ - uint32_t op0 = (iss >> 20) & 0x3, op2 = (iss >> 17) & 0x7; - uint32_t op1 = (iss >> 14) & 0x7, crn = (iss >> 10) & 0xF; - uint32_t crm = (iss >> 1) & 0xF; - - /* Construct HVF system register ID: - * (Op0<<14) | (Op1<<11) | (CRn<<7) | (CRm<<3) | Op2 - */ - hv_sys_reg_t reg = - (hv_sys_reg_t) ((op0 << 14) | (op1 << 11) | (crn << 7) | - (crm << 3) | op2); - - uint64_t value = 0; - - /* ID register emulation: return VZ-sanitized values - * matching a real VZ (Lima) VM BEFORE trying HVF. HVF's - * hv_vcpu_get_sys_reg succeeds for ID registers but returns - * raw hardware values, which include features the - * hypervisor does not actually virtualize. - * - * Values captured from a Lima VZ VM on Apple Silicon via - * inline MRS from EL0 (kernel trap-and-emulate). These are - * checked first, before the HVF call. - */ - bool have_vz_override = false; - - /* ID_AA64MMFR0_EL1 (3,0,0,7,0) */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && - op2 == 0) { - value = 0x00000111ff000000ULL; - have_vz_override = true; - } - - /* ID_AA64MMFR1_EL1 (3,0,0,7,1): VZ returns 0. Raw hardware - * (e.g., 0x11212000) exposes HPDS, PAN, LO, XNX etc. that - * VZ does not virtualize. - */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && - op2 == 1) { - value = 0x0000000000000000ULL; - have_vz_override = true; - } - /* ID_AA64MMFR2_EL1 (3,0,0,7,2): VZ returns 0. */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 7 && - op2 == 2) { - value = 0x0000000000000000ULL; - have_vz_override = true; - } - /* ID_AA64ISAR0_EL1 (3,0,0,6,0) */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 6 && - op2 == 0) { - value = 0x0021100110212120ULL; - have_vz_override = true; - } - /* ID_AA64ISAR1_EL1 (3,0,0,6,1) */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 6 && - op2 == 1) { - value = 0x0000101110211402ULL; - have_vz_override = true; - } - /* ID_AA64PFR0_EL1 (3,0,0,4,0) */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 4 && - op2 == 0) { - value = 0x0001000000110011ULL; - have_vz_override = true; - } - /* ID_AA64PFR1_EL1 (3,0,0,4,1): VZ returns 0. */ - if (op0 == 3 && op1 == 0 && crn == 0 && crm == 4 && - op2 == 1) { - value = 0x0000000000000000ULL; - have_vz_override = true; - } - - if (have_vz_override) { - if (verbose) - log_debug( - "%s: MRS trap: Op0=%u Op1=%u " - "CRn=%u CRm=%u Op2=%u -> 0x%llx (VZ)", - prefix, op0, op1, crn, crm, op2, - (unsigned long long) value); - } - - hv_return_t ret = have_vz_override ? HV_SUCCESS - : hv_vcpu_get_sys_reg( - vcpu, reg, &value); - if (ret != HV_SUCCESS) { - /* HVF does not expose this register. Provide a - * host-side fallback for known registers. - */ - bool have_fallback = false; - - /* CNTFRQ_EL0 (3,3,14,0,0): counter frequency. Read - * directly from host hardware (Apple Silicon uses - * 24MHz). - */ - if (op0 == 3 && op1 == 3 && crn == 14 && crm == 0 && - op2 == 0) { - __asm__ volatile("mrs %0, cntfrq_el0" - : "=r"(value)); - have_fallback = true; - } - - /* Non-ID register fallbacks for registers that HVF does - * not expose. ID registers are handled above (VZ - * overrides). - */ - - if (verbose) { - if (have_fallback) { - log_debug( - "%s: MRS trap: " - "Op0=%u Op1=%u CRn=%u CRm=%u " - "Op2=%u -> 0x%llx (host)", - prefix, op0, op1, crn, crm, op2, - (unsigned long long) value); - } else { - log_debug( - "%s: MRS trap: unknown reg " - "Op0=%u Op1=%u CRn=%u CRm=%u " - "Op2=%u (hv_reg=0x%x) -> 0", - prefix, op0, op1, crn, crm, op2, - (unsigned) reg); - } - } - } else if (verbose) { - log_debug( - "%s: MRS trap: Op0=%u Op1=%u " - "CRn=%u CRm=%u Op2=%u -> 0x%llx", - prefix, op0, op1, crn, crm, op2, - (unsigned long long) value); - } - - hv_vcpu_set_reg(vcpu, HV_REG_X0, value); + vcpu_handle_mrs_trap(vcpu, verbose, prefix); break; } case 9: { - /* HVC #9: W^X page permission toggle for JIT. - * - * Apple HVF enforces W^X: pages cannot be both writable and - * executable simultaneously. JIT code needs to be written - * (RW), then executed (RX). The shim detects permission - * faults (EC=0x20 instruction abort, EC=0x24 data abort) - * and forwards the faulting address here. - * - * Toggling at 2MiB granularity causes thrashing when the - * JIT writes new code and executes existing code within the - * same 2MiB block. Instead, the code splits the 2MiB block - * into 4KiB L3 pages and toggle only the faulting 4KiB - * page. This allows different pages within a 2MiB block to - * have independent RW/RX permissions simultaneously. - * - * x0 = FAR_EL1 (faulting virtual address) x1 = type: 0 = - * exec fault -> flip to RX - * 1 = write fault -> flip to RW - */ - uint64_t far, type; - hv_vcpu_get_reg(vcpu, HV_REG_X0, &far); - hv_vcpu_get_reg(vcpu, HV_REG_X1, &type); - - uint64_t page_start = far & ~(4096ULL - 1); - uint64_t page_end = page_start + 4096; - int new_perms = (type == 0) ? MEM_PERM_RX : MEM_PERM_RW; - - /* Hold mmap_lock for page table modifications AND region - * lookups to prevent races with concurrent - * mmap/mprotect/munmap from other vCPU threads. - */ - pthread_mutex_lock(&mmap_lock); - - /* Check if this is a genuine permission violation (not a - * W^X toggle). If the guest region lacks the required - * permission, deliver SIGSEGV instead of toggling. This - * handles mprotect(PROT_READ), SHM_RDONLY, PROT_NONE, and - * non-exec pages. - */ - { - uint64_t off = far - g->ipa_base; - const guest_region_t *reg = guest_region_find(g, off); - int required = - (type == 1) ? LINUX_PROT_WRITE : LINUX_PROT_EXEC; - if (reg && !(reg->prot & required)) { - pthread_mutex_unlock(&mmap_lock); - uint64_t esr; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); - signal_set_fault_info(LINUX_SEGV_ACCERR, far, esr); - int sig_ret = signal_deliver_fault( - vcpu, g, LINUX_SIGSEGV, &exit_code); - if (sig_ret < 0) - running = false; - break; - } - } - - /* Count W^X toggles for JIT debugging */ - if (type == 0) - atomic_fetch_add(&wxcount_to_rx, 1); - else - atomic_fetch_add(&wxcount_to_rw, 1); - - if (verbose) - log_debug( - "%s: W^X toggle at 0x%llx -> %s (page 0x%llx)", - prefix, (unsigned long long) far, - (type == 0) ? "RX" : "RW", - (unsigned long long) page_start); - uint64_t block_start = far & ~(BLOCK_2MIB - 1); - int sr = guest_split_block(g, block_start); - int ur = - guest_update_perms(g, page_start, page_end, new_perms); - pthread_mutex_unlock(&mmap_lock); - if (verbose && (sr < 0 || ur < 0)) - log_warn( - "%s: W^X toggle FAILED " - "(split=%d update=%d) far=0x%llx", - prefix, sr, ur, (unsigned long long) far); - - /* TLB flush is done by the shim (tlbi_restore_eret) for the - * single faulting page. Clear this thread's pending request - * so the next syscall epilogue does not re-flush the W^X - * page. cpu_tlbi_req is per-vCPU, so this only touches our - * own slot -- concurrent vCPUs are unaffected. - * - * The HVC #9 shim now consumes X8 as a post-HVC marker: 0 - * means W^X succeeded and the shim should run the TLBI - * retry epilogue; 2 means signal_deliver_fault installed a - * handler frame and the shim must drop its saved frame. - * Clear X8 here so a guest's pre-fault X8 value cannot be - * misread as the frame-drop marker after a normal toggle. - */ - tlbi_request_clear(); - hv_vcpu_set_reg(vcpu, HV_REG_X8, 0); + running = vcpu_handle_wx_toggle(g, vcpu, verbose, prefix, + &exit_code); break; } case 10: { - /* HVC #10: BRK from EL0 -> deliver SIGTRAP or ptrace-stop. - * - * If the thread is ptraced, the BRK enters a ptrace-stop - * (the tracer reads/writes registers then CONT's). - * Otherwise the run loop queues SIGTRAP and delivers it via - * the signal frame mechanism. - * - * The shim has already restored all GPRs to their EL0 - * values, so signal_deliver / ptrace_stop read correct - * state. - * - * The Linux kernel sets si_code=TRAP_BRKPT, si_addr=BRK_PC, - * and fault_address=BRK_PC for BRK-triggered SIGTRAP. - */ - uint64_t brk_pc; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ELR_EL1, &brk_pc); - - if (verbose) { - log_debug("%s: BRK at 0x%llx -> %s", prefix, - (unsigned long long) brk_pc, - current_thread->ptraced ? "ptrace-stop" - : "SIGTRAP"); - } - - if (current_thread->ptraced) { - /* Ptrace-stop: suspend vCPU, notify tracer. - * thread_ptrace_stop blocks until tracer CONT's. - */ - int cont_sig = thread_ptrace_stop(current_thread, 5); - if (cont_sig > 0) { - signal_queue(cont_sig); - int sr = signal_deliver(vcpu, g, &exit_code); - if (sr < 0) - running = false; - } - } else { - /* Non-ptraced: deliver SIGTRAP via signal frame. Read - * ESR_EL1 to include in sigcontext. - */ - uint64_t brk_esr; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &brk_esr); - signal_set_fault_info(LINUX_TRAP_BRKPT, brk_pc, - brk_esr); - if (verbose) { - uint64_t thread_blocked = - current_thread ? current_thread->blocked - : 0xDEAD; - log_debug( - "%s: BRK: thread_blocked=0x%llx " - "pending=0x%llx", - prefix, (unsigned long long) thread_blocked, - (unsigned long long) signal_get_state() - ->shared.pending); - } - int sig_ret = signal_deliver_fault( - vcpu, g, LINUX_SIGTRAP, &exit_code); - if (verbose) - log_debug("%s: signal_deliver returned %d", prefix, - sig_ret); - if (sig_ret < 0) { - /* SIG_DFL for SIGTRAP: terminate */ - running = false; - } - } + running = + vcpu_handle_brk(g, vcpu, verbose, prefix, &exit_code); break; } @@ -3936,126 +4060,13 @@ int vcpu_run_loop_with_hooks(hv_vcpu_t vcpu, } case 12: { - /* HVC #12: System instruction trap (EC=0x18 Direction=0). - * The shim forwards trapped cache maintenance instructions - * (DC CVAU, IC IVAU, etc.) here for logging/counting. It - * also passes the original Rt value in X0 so host-side - * emulation can handle MSR writes such as TPIDR_EL0. The - * shim has already advanced PC and will restore X0 from its - * saved frame before returning to EL0. - */ - atomic_fetch_add(&sysreg_write_count, 1); - uint64_t rt_value = 0; - hv_vcpu_get_reg(vcpu, HV_REG_X0, &rt_value); - uint64_t esr; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_ESR_EL1, &esr); - uint32_t iss = (uint32_t) (esr & 0x1FFFFFF); - - /* Decode ISS for system instruction: - * Op0[21:20] Op2[19:17] Op1[16:14] - * CRn[13:10] Rt[9:5] CRm[4:1] Dir[0] - */ - uint32_t op0 = (iss >> 20) & 0x3, op2 = (iss >> 17) & 0x7; - uint32_t op1 = (iss >> 14) & 0x7, crn = (iss >> 10) & 0xF; - uint32_t crm = (iss >> 1) & 0xF, rt = (iss >> 5) & 0x1F; - - /* TPIDR_EL0 (S3_3_C13_C0_2): userspace TLS base. Static - * glibc writes this during early startup. HVF traps the - * MSR, so Linux-compatible execution requires reflecting - * the write into the virtual sysreg. - */ - if (op0 == 3 && op1 == 3 && crn == 13 && crm == 0 && - op2 == 2) { - HV_CHECK(hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_TPIDR_EL0, - rt_value)); - } - if (verbose) { - /* DC CVAU: Op0=1,Op1=3,CRn=7,CRm=11,Op2=1 IC IVAU: - * Op0=1,Op1=3,CRn=7,CRm=5,Op2=1 - */ - const char *name = "unknown"; - if (op0 == 1 && op1 == 3 && crn == 7 && crm == 11 && - op2 == 1) - name = "DC CVAU"; - else if (op0 == 1 && op1 == 3 && crn == 7 && crm == 5 && - op2 == 1) - name = "IC IVAU"; - else if (op0 == 1 && op1 == 3 && crn == 7 && - crm == 10 && op2 == 1) - name = "DC CVAC"; - else if (op0 == 1 && op1 == 3 && crn == 7 && - crm == 14 && op2 == 1) - name = "DC CIVAC"; - else if (op0 == 3 && op1 == 3 && crn == 13 && - crm == 0 && op2 == 2) - name = "MSR TPIDR_EL0"; - log_debug( - "%s: sysreg trap #%llu: %s " - "(Op0=%u Op1=%u CRn=%u CRm=%u Op2=%u " - "Rt=X%u val=0x%llx)", - prefix, - (unsigned long long) atomic_load( - &sysreg_write_count), - name, op0, op1, crn, crm, op2, rt, - (unsigned long long) rt_value); - } + vcpu_handle_sysinstr_trap(vcpu, verbose, prefix); break; } case 2: { - /* HVC #2: Bad exception in guest. Shim clobbers X0-X3,X5 - * with exception info. X4,X6-X30 and SP_EL0 still hold - * faulting values. - */ - uint64_t x0, x1, x2, x3, x5; - hv_vcpu_get_reg(vcpu, HV_REG_X0, &x0); - hv_vcpu_get_reg(vcpu, HV_REG_X1, &x1); - hv_vcpu_get_reg(vcpu, HV_REG_X2, &x2); - hv_vcpu_get_reg(vcpu, HV_REG_X3, &x3); - hv_vcpu_get_reg(vcpu, HV_REG_X5, &x5); - log_error( - "%s: guest exception vec=0x%03llx " - "ESR=0x%llx FAR=0x%llx ELR=0x%llx SPSR=0x%llx", - prefix, (unsigned long long) x5, - (unsigned long long) x0, (unsigned long long) x1, - (unsigned long long) x2, (unsigned long long) x3); - - /* Dump preserved registers for debugging */ - uint64_t sp_el0; - hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_SP_EL0, &sp_el0); - log_error("%s: SP_EL0=0x%llx", prefix, - (unsigned long long) sp_el0); - for (int ri = 4; ri <= 30; ri++) { - /* Skip X5 (clobbered by shim for vec offset) */ - if (ri == 5) - continue; - uint64_t rv; - hv_vcpu_get_reg(vcpu, (hv_reg_t) (HV_REG_X0 + ri), &rv); - log_error("%s: X%-2d=0x%016llx", prefix, ri, - (unsigned long long) rv); - } - - /* Check if FAR looks like a tagged pointer */ - uint64_t far = x1; - uint16_t top16 = (uint16_t) (far >> 48); - if (top16 != 0x0000 && top16 != 0xFFFF) { - log_error( - "%s: FAR tag=0x%04x, extracted addr=0x%llx", - prefix, top16, - (unsigned long long) (far & 0x0000FFFFFFFFFFFFULL)); - } - - { - char detail[128]; - snprintf(detail, sizeof(detail), - "vec=0x%03llx ESR=0x%llx FAR=0x%llx", - (unsigned long long) x5, - (unsigned long long) x0, - (unsigned long long) x1); - crash_report(vcpu, g, CRASH_BAD_EXCEPTION, detail); - } - exit_code = 128; - running = false; + running = + vcpu_handle_bad_exception(g, vcpu, prefix, &exit_code); break; } From 43d963214925b61658a2aac4ecca46d8dc652944 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:31:43 +0800 Subject: [PATCH 03/15] Widen the proved arithmetic Nine proof targets read their sources from files named foo-math.h, each sitting beside the .c it had been split out of. The suffix described how the file came to exist rather than what it is for, and the scattering left no way to ask what the tree actually proves. They move to src/proved/.h, and check-proof-targets.py enforces membership in both directions: a header there that no target proves, and a target naming a file git does not track. Seven targets join them (align, dirent, fdset, iov, pathdepth, slice, timespec), taking the proved surface to 590 obligations over 37 functions, with 88 mutations showing those contracts constrain something rather than merely parse. Writing the contracts found four defects the tests did not. pselect6 honored fd_set bits above nfds, which Linux ignores, so it polled descriptors the caller never named and returned EBADF when one was closed; it also bounded nfds by the host FD_SETSIZE rather than the fd table size, two constants that are both 1024 on macOS and are not the same constant. ppoll and pselect6 truncated a sub-millisecond timeout to poll(0), turning a 500 microsecond wait into a spin. linux_timespec_to_ns_sat overflowed signed 64-bit arithmetic on its way to saturating. align_up_ok rejects only what it must. Padding by align - 1 before testing for overflow refuses an address that is already a multiple and lies within align - 1 of the top of the address space, where rounding up is a no-op that cannot carry. Counting in multiples is exact, and one syntactic k * align hands the alignment postcondition its witness; reaching the same value by adding align to a floor computed earlier needs distributivity the provers do not apply. The proved helpers are used where the bound is a fact about the input, not a restatement of what the caller already established. Guarding fdtable.c's one-line bitmap setters that way put the check on the harmless write, since fd_init_entry indexes fd_table with the same fd on the next line and cannot be guarded from inside a bitmap setter; an analyzer reading the pair then has to assume the table write can land anywhere. Those setters index directly, and fdset.h states the distinction so the next call site does not have to rediscover it. The proof and mutation gates stop keeping a second copy of the target list. mk/analysis.mk derives it from its own VERIFY__SRC variables, scripts/analysis-mk.py is the one reader of those, and the CI matrix is fromJson of "make print-verify-targets" rather than a hand-kept list that silently dropped a target's coverage when it fell out of step. --- .github/workflows/main.yml | 231 ++++---- .gitignore | 1 + mk/analysis.mk | 291 +++++----- mk/tests.mk | 4 +- scripts/analysis-mk.py | 49 ++ scripts/check-char-signedness.py | 4 +- scripts/check-mutant-matrix-sync.py | 116 ---- scripts/check-mutants.py | 549 ++++++++++++++++-- scripts/check-proof-targets.py | 216 +++++++ src/core/guest.c | 2 +- src/core/stack.c | 2 +- src/proved/align.h | 113 ++++ src/{syscall/cmsg-math.h => proved/cmsg.h} | 9 +- src/proved/dirent.h | 148 +++++ src/proved/fdset.h | 176 ++++++ src/{syscall/fuse-math.h => proved/fuse.h} | 6 + src/{core/gva-math.h => proved/gva.h} | 9 + src/proved/iov.h | 87 +++ .../netlink-math.h => proved/netlink.h} | 2 +- src/proved/pathdepth.h | 87 +++ .../sigframe-math.h => proved/sigframe.h} | 2 +- src/proved/slice.h | 65 +++ .../sockaddr-math.h => proved/sockaddr.h} | 0 src/{core/stack-math.h => proved/stack.h} | 2 +- src/proved/timespec.h | 144 +++++ src/runtime/procemu.c | 29 +- src/syscall/fdtable.c | 70 ++- src/syscall/fs.c | 41 +- src/syscall/fuse.c | 40 +- src/syscall/internal.h | 47 +- src/syscall/io.c | 59 +- src/syscall/mem.c | 18 +- src/syscall/net-abi.c | 6 +- src/syscall/net-msg.c | 22 +- src/syscall/netlink.c | 13 +- src/syscall/path.c | 29 +- src/syscall/poll.c | 74 ++- src/syscall/signal.c | 12 +- src/syscall/signal.h | 59 +- src/syscall/syscall.c | 15 +- src/syscall/time.c | 21 +- tests/test-gva-contracts.c | 4 +- tests/test-poll.c | 80 +++ 43 files changed, 2323 insertions(+), 631 deletions(-) create mode 100644 scripts/analysis-mk.py delete mode 100755 scripts/check-mutant-matrix-sync.py create mode 100755 scripts/check-proof-targets.py create mode 100644 src/proved/align.h rename src/{syscall/cmsg-math.h => proved/cmsg.h} (91%) create mode 100644 src/proved/dirent.h create mode 100644 src/proved/fdset.h rename src/{syscall/fuse-math.h => proved/fuse.h} (93%) rename src/{core/gva-math.h => proved/gva.h} (96%) create mode 100644 src/proved/iov.h rename src/{syscall/netlink-math.h => proved/netlink.h} (99%) create mode 100644 src/proved/pathdepth.h rename src/{syscall/sigframe-math.h => proved/sigframe.h} (98%) create mode 100644 src/proved/slice.h rename src/{syscall/sockaddr-math.h => proved/sockaddr.h} (100%) rename src/{core/stack-math.h => proved/stack.h} (99%) create mode 100644 src/proved/timespec.h diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d549fa93..30b338f9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,11 +4,12 @@ # lint : format/newline/security/cppcheck/dispatch on Linux # build-macos : compile + entitlement check on macOS Apple Silicon # tidy-macos : clang-tidy via `make lint` -# verify : Frama-C WP proofs of the attacker-facing arithmetic via -# `make verify`; gating, not advisory -# verify-mutants: shows each proof rejects a known-broken source, one job -# per proof target so the 40-mutation set runs as nine -# parallel shards instead of one long serial job +# verify-mutants: per target, the Frama-C WP proof AND the mutations that +# show it bites; one runner per target, sharded from +# mk/analysis.mk's VERIFY__SRC list +# verify : aggregate check name over that matrix, kept because branch +# protection requires it by name +# verify-mutants-gate: the same aggregate under the mutation-gate name # scan-macos : LLVM scan-build via `make analyze` # infer-macos : Facebook Infer capture + analyze over the full build # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, @@ -117,14 +118,14 @@ jobs: if: ${{ !cancelled() }} run: python3 scripts/gen-syscall-dispatch.py --output "$RUNNER_TEMP/dispatch.h" - - name: Mutation matrix consistency - # verify-mutants (the Frama-C job below) hand-lists the same proof - # targets mk/analysis.mk defines, sharded one job per target. A - # target present in one but not the other silently drops that - # target's mutation coverage from CI with no error, so catch the - # drift here instead. + - name: Proof target consistency + # Three lists name the same proved sources: mk/analysis.mk's targets, + # the verify-mutants matrix below, and src/proved/. A target in one + # but not another either drops that target's mutation coverage from CI + # with no error, or leaves an unproved header sitting in a directory + # whose name claims it is proved. if: ${{ !cancelled() }} - run: python3 scripts/check-mutant-matrix-sync.py + run: python3 scripts/check-proof-targets.py # Build verification on macOS Apple Silicon (no HVF runtime tests). # Hosted runners don't expose Hypervisor.framework, so this job stops at @@ -223,137 +224,65 @@ jobs: - name: clang-tidy (make lint) run: make lint - # Frama-C WP proofs of the attacker-facing arithmetic via `make verify`. + # The proof target list has ONE home, mk/analysis.mk's VERIFY__SRC + # assignments. This job reads it there and the matrix below is built from the + # result, so adding a proof target is a one-file edit and a target can no + # longer exist locally while silently having no CI leg. # - # GATING, unlike tidy-macos and scan-macos: the inputs these proofs cover come - # from untrusted binaries and from the guest itself, so an unproved - # obligation fails the job instead of being logged for review. Without this - # job the proofs are only enforced when a human runs them, and they rot the - # first time someone edits elf.c or gdbstub-rsp.c. - verify: - name: Frama-C WP proofs (make verify) - runs-on: macos-15 - timeout-minutes: 60 - env: - HOMEBREW_NO_INSTALL_CLEANUP: 1 - HOMEBREW_NO_AUTO_UPDATE: 1 - # graphviz/llvm/zlib are frama-c's system dependencies; conf-graphviz - # fails without dot(1). The exact Python formula opam wants moves between - # releases, so it is not listed here: OPAMCONFIRMLEVEL below lets opam - # install whatever depexts it still needs rather than having this list - # guess. Guessing python@3.11 when opam wanted python@3.9 is what made the - # previous attempt abort. - BREW_PKGS: opam gmp pkg-config graphviz llvm@17 zlib - # Without this, opam's "some required external dependencies are missing" - # prompt has no TTY to answer it, silently takes option 4 (abort), and the - # step exits 10. - OPAMCONFIRMLEVEL: unsafe-yes - # The gate fails on any single [Timeout], and a shared runner is slower - # than a dev machine (the three proofs take 3-9s each locally). The job - # already has a 60-minute budget, so headroom here costs nothing and - # removes a flake class that would read as a proof regression. - FRAMAC_TIMEOUT: 120 - # Pinned so the gating proofs run against a known toolchain. The opam - # cache key below is built from these three, so bumping a version here is - # all that is needed to install afresh rather than reuse a stale switch. - FRAMAC_VERSION: "31.0" - ALT_ERGO_VERSION: 2.6.3 - Z3_VERSION: 4.16.0 - OPAMROOT: ${{ github.workspace }}/.opam - OPAM_SWITCH: frama-c-elfuse + # Runs on Linux with no toolchain: "make print-verify-targets" only reads the + # makefile, so this costs seconds and gates nothing. + proof-targets: + name: Enumerate proof targets + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.list.outputs.targets }} steps: - name: Checkout uses: actions/checkout@v7 - - name: Cache Homebrew downloads - uses: actions/cache@v6 - with: - path: ~/Library/Caches/Homebrew/downloads - key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} - - - name: Install Homebrew packages - # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. - run: | - set -euo pipefail - brew install --quiet $BREW_PKGS - - # Building Frama-C and the provers from source takes tens of minutes, so - # the whole opam root is cached. Bump the key suffix to force a rebuild. - - name: Cache opam switch - id: opam-cache - uses: actions/cache@v6 - with: - path: ${{ env.OPAMROOT }} - # Keyed on the pinned versions, so changing any of them installs - # afresh instead of silently reusing a stale toolchain. - key: opam-${{ runner.os }}-${{ runner.arch }}-frama-c${{ env.FRAMAC_VERSION }}-ae${{ env.ALT_ERGO_VERSION }}-z3${{ env.Z3_VERSION }} - - - name: Install Frama-C, Alt-Ergo, Z3 - if: steps.opam-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - opam init -y --bare --disable-sandboxing - opam switch create "$OPAM_SWITCH" 4.14.1 - eval "$(opam env --switch="$OPAM_SWITCH")" - # No --assume-depexts: the system packages are installed above, and - # asserting they exist when they do not is what made conf-graphviz - # fail with "dot: command not found". - opam install -y \ - frama-c.$FRAMAC_VERSION \ - alt-ergo.$ALT_ERGO_VERSION \ - z3.$Z3_VERSION - - - name: Prove the parsers and translation (make verify) - # why3 config detect runs here rather than in the install step: it - # writes ~/.why3.conf, which lives outside OPAMROOT and so is absent on - # a cache hit. Skipping it makes WP abort with "Prover not found in - # why3.conf" instead of reporting unproved obligations, which the gate - # would then report as "Frama-C emitted no result". + - name: Read the targets from mk/analysis.mk + id: list run: | set -euo pipefail - eval "$(opam env --switch="$OPAM_SWITCH")" - why3 config detect - frama-c -version - make verify - - - name: Upload prover log - if: always() - uses: actions/upload-artifact@v7 - with: - name: verify-logs - path: build/verify-*.log - if-no-files-found: warn - - # Shows each Frama-C proof target rejects a known-broken source. Split from - # the "verify" job (needs: verify, so a broken proof fails fast without - # spending nine runners on mutating it) and sharded one job per proof - # target: a caught mutation grinds against every unprovable goal until - # FRAMAC_TIMEOUT, so the 40-mutation set run as one job is minutes where - # "make verify" is seconds. Sharding trades that for nine runners in - # parallel, each bounded by its own target's mutation count instead of the - # whole set's. + targets=$(make print-verify-targets) + test -n "$targets" + json=$(printf '%s\n' "$targets" | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "targets=$json" >> "$GITHUB_OUTPUT" + echo "proof targets: $json" + + # Frama-C WP proofs of the attacker-facing arithmetic, plus the mutation gate + # that shows those proofs bite. One runner per proof target. # - # The matrix list is VERIFY__SRC's targets from mk/analysis.mk, hand-kept - # in step: a target missing here silently drops its mutation coverage from - # CI with no error, so add new proof targets to both places. + # GATING, unlike tidy-macos and scan-macos: the inputs these proofs cover come + # from untrusted binaries and from the guest itself, so an unproved + # obligation fails the job instead of being logged for review. Without this + # job the proofs are only enforced when a human runs them, and they rot the + # first time someone edits elf.c or gdbstub-rsp.c. + # + # Proves one target and shows its mutations are rejected, one runner per + # target. Both halves live here because they are the same work: check-mutants + # runs "make verify-" on an UNMUTATED copy as its control, so a + # separate serial verify job proved all sixteen targets and then every shard + # proved its own target over again. + # + # Sharding is what makes the mutation half affordable at all: a caught + # mutation grinds against every unprovable goal until FRAMAC_TIMEOUT, so the + # whole set on one runner is minutes where a single proof is seconds. Running + # the proof first inside the shard keeps the fast failure the old "needs: + # verify" edge gave, now per target rather than across all of them, and + # without a barrier that made every shard wait for the slowest proof. + # + # The matrix comes from the proof-targets job above, which reads + # mk/analysis.mk, so this list cannot drift from the targets that exist. verify-mutants: - name: Mutation gate (${{ matrix.target }}) - needs: verify + name: Proof and mutations (${{ matrix.target }}) + needs: proof-targets runs-on: macos-15 timeout-minutes: 60 strategy: fail-fast: false matrix: - target: - - cmsg - - elf - - fuse - - gva - - netlink - - rsp - - sigframe - - sockaddr - - stack + target: ${{ fromJson(needs.proof-targets.outputs.targets) }} env: HOMEBREW_NO_INSTALL_CLEANUP: 1 HOMEBREW_NO_AUTO_UPDATE: 1 @@ -416,10 +345,21 @@ jobs: alt-ergo.$ALT_ERGO_VERSION \ z3.$Z3_VERSION + - name: Prove the target (make verify-) + # First, so a broken proof fails this shard in seconds instead of after + # its mutation set. check-mutants would catch it too, through + # check_baseline, but only after paying for the setup a second time and + # with a message about infrastructure rather than about the proof. + run: | + set -euo pipefail + eval "$(opam env --switch="$OPAM_SWITCH")" + why3 config detect + frama-c -version + make verify-${{ matrix.target }} + - name: Prove the gate bites (make verify-mutants) - # why3 config detect is required here too: it writes ~/.why3.conf, - # which lives outside OPAMROOT and so is absent on a cache hit even - # though the verify job already ran this once on its own runner. + # why3 config detect is cheap and idempotent; the proof step above + # already ran it on this runner. # # MUTANT_JOBS is set explicitly for the same reason as before # sharding: this runner has few enough cores that the script's @@ -447,11 +387,34 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: verify-mutants-logs-${{ matrix.target }} - path: build/verify-mutants/*.log + name: verify-logs-${{ matrix.target }} + path: | + build/verify-*.log + build/verify-mutants/*.log if-no-files-found: warn retention-days: 7 + # The proving moved into the matrix above, but this check name predates that + # and branch protection requires it by name, so it stays as an aggregate over + # the same matrix. The "(make verify)" suffix is kept for that continuity + # alone: this job runs no proofs itself, and renaming it would silently + # unrequire the check until someone updated the branch rule to match. + verify: + name: Frama-C WP proofs (make verify) + needs: verify-mutants + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - name: Report the matrix result + run: | + set -euo pipefail + result='${{ needs.verify-mutants.result }}' + if [ "$result" != "success" ]; then + echo "proof matrix did not succeed: $result" >&2 + exit 1 + fi + echo "every proof target discharged" + # One stable check name covering the whole mutation matrix, so branch # protection has something to require. The matrix leg names carry the target # in them ("Mutation gate (fuse)"), which means every added proof target diff --git a/.gitignore b/.gitignore index a01f8957..642badb3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ lib/modules/ *.o *.bin __pycache__ +.frama-c/ diff --git a/mk/analysis.mk b/mk/analysis.mk index 3da6e0dd..6f1bbdb8 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -1,10 +1,8 @@ # Static analysis and formatting -.PHONY: lint analyze check-format indent verify verify-elf verify-rsp \ - verify-gva verify-cmsg verify-fuse verify-stack verify-sockaddr \ - verify-netlink verify-sigframe \ +.PHONY: lint analyze check-format indent verify \ check-contracts verify-mutants check-char-signedness \ - infer-uninit + print-verify-targets infer-uninit CLANG_TIDY ?= clang-tidy INFER ?= infer @@ -19,8 +17,21 @@ SHELL_SCRIPTS := $(shell git ls-files --cached --others --exclude-standard \ PYTHON_FORMAT_FILES := $(shell git ls-files --cached --others \ --exclude-standard -- '*.py') +# Missing-tool diagnostics, in the shape the verify-* targets already use: +# name the tool, name the install, fail on purpose. Without this a developer +# running lint/analyze/infer-uninit gets "make: clang-tidy: No such file or +# directory / Error 1", which reads like a broken Makefile rather than a +# missing dependency, on three of the eleven CI jobs. +define require-tool + @command -v $(1) >/dev/null 2>&1 || { \ + printf " $(RED)%s not found$(RESET) (%s)\n" "$(1)" "$(2)"; \ + exit 1; \ + } +endef + ## Run clang-tidy on all source files lint: $(BUILD_DIR)/shim_blob.h $(BUILD_DIR)/version.h + $(call require-tool,$(CLANG_TIDY),brew install llvm -- or set CLANG_TIDY=) @echo " TIDY src/" $(Q)$(CLANG_TIDY) $(SRCS) -- $(CFLAGS) -Isrc -I$(BUILD_DIR) @@ -38,7 +49,7 @@ lint: $(BUILD_DIR)/shim_blob.h $(BUILD_DIR)/version.h # they pass a malloc'd ph_buf plus distinct stack locals, but a future # elf_segment_extent(..., &x, &x) would invalidate the proof with no diagnostic. # -# That caveat is general, and it bites hardest for gva-math.h: guest.c cannot be +# That caveat is general, and it bites hardest for proved/gva.h: guest.c cannot be # given to Frama-C at all, so nothing here checks that its call sites honor the # nine "requires" clauses there. check-acsl-coverage.py closes the other # direction (a contract assumed because its function was left out of -wp-fct); @@ -48,7 +59,7 @@ lint: $(BUILD_DIR)/shim_blob.h $(BUILD_DIR)/version.h # with -DELFUSE_CONTRACT_ASSERT so the five expressible clauses are checked on # every call the suite makes. The four pointer clauses (\valid x3, \separated) # have no C expression and stay review-only. The checks call the *_args_ok -# predicates in gva-math.h, whose <==> contracts are proved here, so a check +# predicates in proved/gva.h, whose <==> contracts are proved here, so a check # that drifted weaker than the clause it mirrors fails verify-gva. # # Install: opam install frama-c, then why3 config detect (without the latter WP @@ -124,13 +135,22 @@ VERIFY_ELF_SCAN := src/core/elf.c src/core/elf.h src/utils.h VERIFY_ELF_CLAIM := for ANY byte sequence an untrusted ELF can supply VERIFY_ELF_UNPROVED := the pread/malloc I/O around them stays test-covered -VERIFY_GVA_SRC := src/core/gva-math.h +# Proved with the call-site checks compiled IN. Without this the prover never +# sees the GVA_CONTRACT_ASSERT calls, and a check wired to the wrong predicate +# or handed permuted arguments would show up only as a spurious runtime abort +# under check-contracts. With it, WP must discharge each assert from the very +# requires clause it mirrors, so the wiring is machine-checked too. +# +# The only target that needs a preprocessor define, which is why the rule +# template below emits CPP_DEFS for every target and empty for the rest. +VERIFY_GVA_CPP_DEFS := -DELFUSE_CONTRACT_ASSERT +VERIFY_GVA_SRC := src/proved/gva.h VERIFY_GVA_FCTS := gva_pt_table_offset gva_leaf_target gva_chunk_clamp \ gva_span_ok gva_leaf_target_args_ok \ gva_chunk_clamp_args_ok -VERIFY_GVA_MIN_GOALS ?= 66 +VERIFY_GVA_MIN_GOALS ?= 69 VERIFY_GVA_MODEL := typed -VERIFY_GVA_SCAN := src/core/gva-math.h +VERIFY_GVA_SCAN := src/proved/gva.h VERIFY_GVA_CLAIM := for ANY guest address, length, and page-table content VERIFY_GVA_UNPROVED := the walk and copy loops around them stay test-covered @@ -151,56 +171,112 @@ VERIFY_RSP_SCAN := src/debug/gdbstub-rsp.c src/utils.h VERIFY_RSP_CLAIM := for ANY packet bytes a GDB remote can send VERIFY_RSP_UNPROVED := the socket I/O and framing loop stay test-covered -VERIFY_CMSG_SRC := src/syscall/cmsg-math.h +VERIFY_CMSG_SRC := src/proved/cmsg.h VERIFY_CMSG_FCTS := cmsg_entry_bounds -VERIFY_CMSG_MIN_GOALS ?= 17 +VERIFY_CMSG_MIN_GOALS ?= 19 VERIFY_CMSG_MODEL := typed -VERIFY_CMSG_SCAN := src/syscall/cmsg-math.h +VERIFY_CMSG_SCAN := src/proved/cmsg.h VERIFY_CMSG_CLAIM := for ANY control-message bytes a guest can supply VERIFY_CMSG_UNPROVED := the walk loop and the host cmsg build stay test-covered -VERIFY_FUSE_SRC := src/syscall/fuse-math.h +VERIFY_FUSE_SRC := src/proved/fuse.h VERIFY_FUSE_FCTS := fuse_frame_count_ok fuse_reply_extent \ fuse_clamp_negotiated_write -VERIFY_FUSE_MIN_GOALS ?= 27 +VERIFY_FUSE_MIN_GOALS ?= 28 VERIFY_FUSE_MODEL := typed -VERIFY_FUSE_SCAN := src/syscall/fuse-math.h +VERIFY_FUSE_SCAN := src/proved/fuse.h VERIFY_FUSE_CLAIM := for ANY reply frame a guest FUSE daemon can write VERIFY_FUSE_UNPROVED := the per-opcode payload extents stay test-covered -VERIFY_STACK_SRC := src/core/stack-math.h +VERIFY_STACK_SRC := src/proved/stack.h VERIFY_STACK_FCTS := stack_take stack_align_down stack_pushed_words \ stack_final_sp VERIFY_STACK_MIN_GOALS ?= 36 VERIFY_STACK_MODEL := typed -VERIFY_STACK_SCAN := src/core/stack-math.h +VERIFY_STACK_SCAN := src/proved/stack.h VERIFY_STACK_CLAIM := for ANY argv, envp, and auxv set a guest can present VERIFY_STACK_UNPROVED := the string writes and push loop stay test-covered -VERIFY_SOCKADDR_SRC := src/syscall/sockaddr-math.h +VERIFY_SOCKADDR_SRC := src/proved/sockaddr.h VERIFY_SOCKADDR_FCTS := sockaddr_len_ok sockaddr_payload_len VERIFY_SOCKADDR_MIN_GOALS ?= 11 VERIFY_SOCKADDR_MODEL := typed -VERIFY_SOCKADDR_SCAN := src/syscall/sockaddr-math.h +VERIFY_SOCKADDR_SCAN := src/proved/sockaddr.h VERIFY_SOCKADDR_CLAIM := for ANY address length a guest or host can present VERIFY_SOCKADDR_UNPROVED := the family translation and memcpy stay test-covered -VERIFY_NETLINK_SRC := src/syscall/netlink-math.h +VERIFY_NETLINK_SRC := src/proved/netlink.h VERIFY_NETLINK_FCTS := netlink_align_up netlink_rta_bounds netlink_msg_span VERIFY_NETLINK_MIN_GOALS ?= 44 VERIFY_NETLINK_MODEL := typed -VERIFY_NETLINK_SCAN := src/syscall/netlink-math.h +VERIFY_NETLINK_SCAN := src/proved/netlink.h VERIFY_NETLINK_CLAIM := for ANY netlink message bytes a guest can send VERIFY_NETLINK_UNPROVED := the walk loops and attribute copies stay test-covered -VERIFY_SIGFRAME_SRC := src/syscall/sigframe-math.h +VERIFY_SIGFRAME_SRC := src/proved/sigframe.h VERIFY_SIGFRAME_FCTS := sigframe_base VERIFY_SIGFRAME_MIN_GOALS ?= 15 VERIFY_SIGFRAME_MODEL := typed -VERIFY_SIGFRAME_SCAN := src/syscall/sigframe-math.h +VERIFY_SIGFRAME_SCAN := src/proved/sigframe.h VERIFY_SIGFRAME_CLAIM := for ANY interrupted stack pointer and frame size VERIFY_SIGFRAME_UNPROVED := the frame field layout is not covered at all yet +VERIFY_DIRENT_SRC := src/proved/dirent.h +VERIFY_DIRENT_FCTS := dirent_reclen dirent_record_bounds +VERIFY_DIRENT_MIN_GOALS ?= 30 +VERIFY_DIRENT_MODEL := typed +VERIFY_DIRENT_SCAN := src/proved/dirent.h +VERIFY_DIRENT_CLAIM := for ANY name length a host or FUSE directory can present +VERIFY_DIRENT_UNPROVED := the readdir walk and the name translation stay test-covered + +VERIFY_IOV_SRC := src/proved/iov.h +VERIFY_IOV_FCTS := iov_count_ok iov_total_add +VERIFY_IOV_MIN_GOALS ?= 17 +VERIFY_IOV_MODEL := typed +VERIFY_IOV_SCAN := src/proved/iov.h +VERIFY_IOV_CLAIM := for ANY iovec array a guest can write +VERIFY_IOV_UNPROVED := the per-entry guest_ptr bounds stay test-covered + +VERIFY_FDSET_SRC := src/proved/fdset.h +VERIFY_FDSET_FCTS := fdset_words fdset_fd_index fdset_slot +VERIFY_FDSET_MIN_GOALS ?= 43 +VERIFY_FDSET_MODEL := typed +VERIFY_FDSET_SCAN := src/proved/fdset.h +VERIFY_FDSET_CLAIM := for ANY nfds, fd_set bit, or fd-table slot index +VERIFY_FDSET_UNPROVED := the poll translation and the result writeback stay test-covered + +VERIFY_TIMESPEC_SRC := src/proved/timespec.h +VERIFY_TIMESPEC_FCTS := timespec_valid timespec_to_ns_sat timespec_to_poll_ms +VERIFY_TIMESPEC_MIN_GOALS ?= 35 +VERIFY_TIMESPEC_MODEL := typed +VERIFY_TIMESPEC_SCAN := src/proved/timespec.h +VERIFY_TIMESPEC_CLAIM := for ANY timespec a guest can write +VERIFY_TIMESPEC_UNPROVED := the deadline bookkeeping around them stays test-covered + +VERIFY_SLICE_SRC := src/proved/slice.h +VERIFY_SLICE_FCTS := slice_clamp +VERIFY_SLICE_MIN_GOALS ?= 17 +VERIFY_SLICE_MODEL := typed +VERIFY_SLICE_SCAN := src/proved/slice.h +VERIFY_SLICE_CLAIM := for ANY offset and count a guest can pass to a synthesized read +VERIFY_SLICE_UNPROVED := the buffer synthesis itself stays test-covered + +VERIFY_ALIGN_SRC := src/proved/align.h +VERIFY_ALIGN_FCTS := align_up_ok window_fits +VERIFY_ALIGN_MIN_GOALS ?= 24 +VERIFY_ALIGN_MODEL := typed +VERIFY_ALIGN_SCAN := src/proved/align.h +VERIFY_ALIGN_CLAIM := for ANY address, alignment, and search window +VERIFY_ALIGN_UNPROVED := the region-array walk around them stays test-covered + +VERIFY_PATHDEPTH_SRC := src/proved/pathdepth.h +VERIFY_PATHDEPTH_FCTS := path_depth_push path_depth_pop +VERIFY_PATHDEPTH_MIN_GOALS ?= 24 +VERIFY_PATHDEPTH_MODEL := typed +VERIFY_PATHDEPTH_SCAN := src/proved/pathdepth.h +VERIFY_PATHDEPTH_CLAIM := for ANY component depth a guest path can reach +VERIFY_PATHDEPTH_UNPROVED := the component scan and the mark writes stay test-covered + # -wp-fct wants one comma-separated argument; the lists stay space-separated so # the recipe can iterate them for the banner. verify_empty := @@ -213,119 +289,47 @@ commafy = $(subst $(verify_space),$(verify_comma),$(strip $(1))) # assignments here, and the target name to the shared rule; the recipe itself is # written once. -## Prove the ELF parser cannot be driven out of bounds by a crafted binary -verify-elf: NAME := elf -verify-elf: TARGET := elf -verify-elf: SRC := $(VERIFY_ELF_SRC) -verify-elf: FCTS := $(VERIFY_ELF_FCTS) -verify-elf: FCT_ARG := $(call commafy,$(VERIFY_ELF_FCTS)) -verify-elf: MIN_GOALS := $(VERIFY_ELF_MIN_GOALS) -verify-elf: MODEL := $(VERIFY_ELF_MODEL) -verify-elf: SCAN := $(VERIFY_ELF_SCAN) -verify-elf: CLAIM := $(VERIFY_ELF_CLAIM) -verify-elf: UNPROVED := $(VERIFY_ELF_UNPROVED) - -## Prove guest address translation cannot compute an out-of-bounds window -# Prove with the call-site checks compiled IN. Without this the prover never -# sees the GVA_CONTRACT_ASSERT calls, and a check wired to the wrong predicate -# or handed permuted arguments would show up only as a spurious runtime abort -# under check-contracts. With it, WP must discharge each assert from the very -# requires clause it mirrors, so the wiring is machine-checked too. -verify-gva: CPP_DEFS := -DELFUSE_CONTRACT_ASSERT -verify-gva: NAME := gva -verify-gva: TARGET := gva -verify-gva: SRC := $(VERIFY_GVA_SRC) -verify-gva: FCTS := $(VERIFY_GVA_FCTS) -verify-gva: FCT_ARG := $(call commafy,$(VERIFY_GVA_FCTS)) -verify-gva: MIN_GOALS := $(VERIFY_GVA_MIN_GOALS) -verify-gva: MODEL := $(VERIFY_GVA_MODEL) -verify-gva: SCAN := $(VERIFY_GVA_SCAN) -verify-gva: CLAIM := $(VERIFY_GVA_CLAIM) -verify-gva: UNPROVED := $(VERIFY_GVA_UNPROVED) - -## Prove the GDB RSP parser cannot be driven out of bounds by a remote -verify-rsp: NAME := rsp -verify-rsp: TARGET := rsp -verify-rsp: SRC := $(VERIFY_RSP_SRC) -verify-rsp: FCTS := $(VERIFY_RSP_FCTS) -verify-rsp: FCT_ARG := $(call commafy,$(VERIFY_RSP_FCTS)) -verify-rsp: MIN_GOALS := $(VERIFY_RSP_MIN_GOALS) -verify-rsp: MODEL := $(VERIFY_RSP_MODEL) -verify-rsp: SCAN := $(VERIFY_RSP_SCAN) -verify-rsp: CLAIM := $(VERIFY_RSP_CLAIM) -verify-rsp: UNPROVED := $(VERIFY_RSP_UNPROVED) - -## Prove the control-message walk cannot be driven out of the control buffer -verify-cmsg: NAME := cmsg -verify-cmsg: TARGET := cmsg -verify-cmsg: SRC := $(VERIFY_CMSG_SRC) -verify-cmsg: FCTS := $(VERIFY_CMSG_FCTS) -verify-cmsg: FCT_ARG := $(call commafy,$(VERIFY_CMSG_FCTS)) -verify-cmsg: MIN_GOALS := $(VERIFY_CMSG_MIN_GOALS) -verify-cmsg: MODEL := $(VERIFY_CMSG_MODEL) -verify-cmsg: SCAN := $(VERIFY_CMSG_SCAN) -verify-cmsg: CLAIM := $(VERIFY_CMSG_CLAIM) -verify-cmsg: UNPROVED := $(VERIFY_CMSG_UNPROVED) - -## Prove a hostile FUSE daemon cannot drive a reply past its own frame -verify-fuse: NAME := fuse -verify-fuse: TARGET := fuse -verify-fuse: SRC := $(VERIFY_FUSE_SRC) -verify-fuse: FCTS := $(VERIFY_FUSE_FCTS) -verify-fuse: FCT_ARG := $(call commafy,$(VERIFY_FUSE_FCTS)) -verify-fuse: MIN_GOALS := $(VERIFY_FUSE_MIN_GOALS) -verify-fuse: MODEL := $(VERIFY_FUSE_MODEL) -verify-fuse: SCAN := $(VERIFY_FUSE_SCAN) -verify-fuse: CLAIM := $(VERIFY_FUSE_CLAIM) -verify-fuse: UNPROVED := $(VERIFY_FUSE_UNPROVED) - -## Prove the initial stack stays in its region and lands SP aligned on argc -verify-stack: NAME := stack -verify-stack: TARGET := stack -verify-stack: SRC := $(VERIFY_STACK_SRC) -verify-stack: FCTS := $(VERIFY_STACK_FCTS) -verify-stack: FCT_ARG := $(call commafy,$(VERIFY_STACK_FCTS)) -verify-stack: MIN_GOALS := $(VERIFY_STACK_MIN_GOALS) -verify-stack: MODEL := $(VERIFY_STACK_MODEL) -verify-stack: SCAN := $(VERIFY_STACK_SCAN) -verify-stack: CLAIM := $(VERIFY_STACK_CLAIM) -verify-stack: UNPROVED := $(VERIFY_STACK_UNPROVED) - -## Prove sockaddr reshaping cannot overrun either representation -verify-sockaddr: NAME := sockaddr -verify-sockaddr: TARGET := sockaddr -verify-sockaddr: SRC := $(VERIFY_SOCKADDR_SRC) -verify-sockaddr: FCTS := $(VERIFY_SOCKADDR_FCTS) -verify-sockaddr: FCT_ARG := $(call commafy,$(VERIFY_SOCKADDR_FCTS)) -verify-sockaddr: MIN_GOALS := $(VERIFY_SOCKADDR_MIN_GOALS) -verify-sockaddr: MODEL := $(VERIFY_SOCKADDR_MODEL) -verify-sockaddr: SCAN := $(VERIFY_SOCKADDR_SCAN) -verify-sockaddr: CLAIM := $(VERIFY_SOCKADDR_CLAIM) -verify-sockaddr: UNPROVED := $(VERIFY_SOCKADDR_UNPROVED) - -## Prove the netlink TLV walks stay in the message and terminate -verify-netlink: NAME := netlink -verify-netlink: TARGET := netlink -verify-netlink: SRC := $(VERIFY_NETLINK_SRC) -verify-netlink: FCTS := $(VERIFY_NETLINK_FCTS) -verify-netlink: FCT_ARG := $(call commafy,$(VERIFY_NETLINK_FCTS)) -verify-netlink: MIN_GOALS := $(VERIFY_NETLINK_MIN_GOALS) -verify-netlink: MODEL := $(VERIFY_NETLINK_MODEL) -verify-netlink: SCAN := $(VERIFY_NETLINK_SCAN) -verify-netlink: CLAIM := $(VERIFY_NETLINK_CLAIM) -verify-netlink: UNPROVED := $(VERIFY_NETLINK_UNPROVED) - -## Prove the signal frame lands aligned and below the interrupted stack pointer -verify-sigframe: NAME := sigframe -verify-sigframe: TARGET := sigframe -verify-sigframe: SRC := $(VERIFY_SIGFRAME_SRC) -verify-sigframe: FCTS := $(VERIFY_SIGFRAME_FCTS) -verify-sigframe: FCT_ARG := $(call commafy,$(VERIFY_SIGFRAME_FCTS)) -verify-sigframe: MIN_GOALS := $(VERIFY_SIGFRAME_MIN_GOALS) -verify-sigframe: MODEL := $(VERIFY_SIGFRAME_MODEL) -verify-sigframe: SCAN := $(VERIFY_SIGFRAME_SCAN) -verify-sigframe: CLAIM := $(VERIFY_SIGFRAME_CLAIM) -verify-sigframe: UNPROVED := $(VERIFY_SIGFRAME_UNPROVED) +# GNU make has no lowercase function, and the variable names are upper while +# the target names are lower. One $(subst) chain per letter actually used by a +# target name is enough and stays readable; a new target using a letter not +# listed here shows up immediately as a literal upper-case character in the +# rule name rather than silently misbehaving. +lc = $(subst A,a,$(subst B,b,$(subst C,c,$(subst D,d,$(subst E,e,$(subst F,f,$(subst G,g,$(subst H,h,$(subst I,i,$(subst K,k,$(subst L,l,$(subst M,m,$(subst N,n,$(subst O,o,$(subst P,p,$(subst Q,q,$(subst R,r,$(subst S,s,$(subst T,t,$(subst U,u,$(subst V,v,$(subst X,x,$(1))))))))))))))))))))))) + +# The proof targets, derived rather than listed. Make knows every variable it +# has read, so the set of VERIFY__SRC assignments above IS the target list; +# writing it out again is how the four copies of it (this file twice, the CI +# matrix, and src/proved/) drifted apart in the first place. +VERIFY_TARGETS := $(sort $(patsubst VERIFY_%_SRC,%,$(filter VERIFY_%_SRC,$(.VARIABLES)))) +VERIFY_TARGET_NAMES := $(call lc,$(VERIFY_TARGETS)) +VERIFY_RULES := $(addprefix verify-,$(VERIFY_TARGET_NAMES)) + +# Declared here, not in the .PHONY at the top of the file: VERIFY_RULES does +# not exist yet at that point and would expand to nothing. +.PHONY: $(VERIFY_RULES) + +## Print the proof target names, one per line (CI reads this to build its matrix) +print-verify-targets: + @printf '%s\n' $(VERIFY_TARGET_NAMES) + +# One rule template, instantiated per target. The target-specific variables +# below are exactly what the shared recipe consumes; NAME and TARGET differ only +# because a mutation run overrides NAME to keep concurrent logs apart. +define verify-target-vars +verify-$(call lc,$(1)): NAME := $(call lc,$(1)) +verify-$(call lc,$(1)): TARGET := $(call lc,$(1)) +verify-$(call lc,$(1)): SRC := $$(VERIFY_$(1)_SRC) +verify-$(call lc,$(1)): FCTS := $$(VERIFY_$(1)_FCTS) +verify-$(call lc,$(1)): FCT_ARG := $$(call commafy,$$(VERIFY_$(1)_FCTS)) +verify-$(call lc,$(1)): MIN_GOALS := $$(VERIFY_$(1)_MIN_GOALS) +verify-$(call lc,$(1)): MODEL := $$(VERIFY_$(1)_MODEL) +verify-$(call lc,$(1)): SCAN := $$(VERIFY_$(1)_SCAN) +verify-$(call lc,$(1)): CLAIM := $$(VERIFY_$(1)_CLAIM) +verify-$(call lc,$(1)): UNPROVED := $$(VERIFY_$(1)_UNPROVED) +verify-$(call lc,$(1)): CPP_DEFS := $$(VERIFY_$(1)_CPP_DEFS) +endef + +$(foreach t,$(VERIFY_TARGETS),$(eval $(call verify-target-vars,$(t)))) # NAME and TARGET look redundant and are not. NAME picks the log path and is # overridden per mutation run so concurrent runs do not share a file; TARGET is @@ -340,8 +344,7 @@ verify-sigframe: UNPROVED := $(VERIFY_SIGFRAME_UNPROVED) # lives in scripts/check-wp-result.py: as a shell recipe it needed every $ # doubled and every line continued, which put the gate that matters out of # reach of any test. -verify-elf verify-rsp verify-gva verify-cmsg verify-fuse verify-stack \ - verify-sockaddr verify-netlink verify-sigframe: | $(BUILD_DIR) +$(VERIFY_RULES): | $(BUILD_DIR) @command -v $(FRAMAC) >/dev/null 2>&1 || { \ printf "$(RED)frama-c not found$(RESET) "; \ printf "(set FRAMAC=, or eval \$$(opam env --switch=))\n"; \ @@ -410,10 +413,9 @@ check-char-signedness: $(Q)python3 scripts/check-char-signedness.py --cc '$(CC)' ## Run every Frama-C proof -verify: verify-elf verify-gva verify-rsp verify-cmsg verify-fuse verify-stack \ - verify-sockaddr verify-netlink verify-sigframe +verify: $(VERIFY_RULES) -## Rebuild with the gva-math.h precondition checks live, then run the suite +## Rebuild with the proved/gva.h precondition checks live, then run the suite # # Separate from "make check" rather than folded into it: gva_leaf_target and # gva_chunk_clamp sit on the guest_read / guest_write hot path, and the tree has @@ -425,15 +427,13 @@ verify: verify-elf verify-gva verify-rsp verify-cmsg verify-fuse verify-stack \ # checks on its hot path. A separate tree also means no -B is needed, since it # starts empty. check-contracts: - @echo " CONTRACT gva-math.h call-site preconditions (5 of 9 clauses)" + @echo " CONTRACT proved/gva.h call-site preconditions (5 of 9 clauses)" $(Q)$(MAKE) BUILD_DIR=$(BUILD_DIR)/contracts \ EXTRA_CFLAGS="-DELFUSE_CONTRACT_ASSERT $(EXTRA_CFLAGS)" check ## Re-run Infer with the uninitialized-value checker that .inferconfig disables infer-uninit: | $(BUILD_DIR) - @command -v $(INFER) >/dev/null 2>&1 || { \ - printf " $(RED)infer not found$(RESET) (set INFER=)\n"; exit 1; \ - } + $(call require-tool,$(INFER),brew install infer -- or set INFER=) @echo " INFER uninitialized-value checker (disabled in .inferconfig)" @echo " A count of 0 means the suppression is no longer needed and" @echo " .inferconfig should be deleted. Anything else is the known" @@ -459,6 +459,7 @@ infer-uninit: | $(BUILD_DIR) ## Run clang static analyzer (scan-build) analyze: + $(call require-tool,scan-build,brew install llvm) @echo " SCAN elfuse" $(Q)scan-build --use-cc=$(CC) $(MAKE) -B elfuse diff --git a/mk/tests.mk b/mk/tests.mk index d58dde8c..75b6cb17 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -207,7 +207,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage test-config \ $(call run-lane,test-sysroot-path-matrix,addressing modes agree across the path matrix) $(call run-lane,test-usage-synopsis,usage synopsis renderings) $(call run-lane,test-shebang-host,shebang parser unit test) - $(call run-lane,test-gva-contracts,gva-math.h call-site contract checks) + $(call run-lane,test-gva-contracts,proved/gva.h call-site contract checks) $(call run-lane,test-proctitle-host,proctitle argv-tail regression) $(call run-lane,test-proctitle-low-stack,proctitle low-stack regression) $(call run-lane,test-busybox,busybox applet validation) @@ -1479,6 +1479,6 @@ probe-volume-naming: $(BUILD_DIR)/probe-volume-naming test-shebang-host: $(BUILD_DIR)/test-shebang-host $(BUILD_DIR)/test-shebang-host -## Run the gva-math.h call-site precondition checks (skips without the flag) +## Run the proved/gva.h call-site precondition checks (skips without the flag) test-gva-contracts: $(BUILD_DIR)/test-gva-contracts $(BUILD_DIR)/test-gva-contracts diff --git a/scripts/analysis-mk.py b/scripts/analysis-mk.py new file mode 100644 index 00000000..79157444 --- /dev/null +++ b/scripts/analysis-mk.py @@ -0,0 +1,49 @@ +"""The one reader of mk/analysis.mk's VERIFY__* variables. + +check-mutants.py and check-proof-targets.py both need the proof-target table, +and each had grown its own regex over the same lines: three patterns spelling +"VERIFY__SRC" three ways, differing only in which capture group they kept. +They agree today, so nothing was broken; they are three places to update when +the variable naming changes, in a pair of scripts whose entire job is catching +exactly that kind of drift somewhere else. + +Load this instead of re-deriving it. The filename is kebab-case per CLAUDE.md, +which no import statement can name, so both consumers pull it in by path with +importlib; see _load_analysis_mk in either script. +""" + +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ANALYSIS_MK = ROOT / "mk" / "analysis.mk" + + +def text(): + """mk/analysis.mk as text.""" + return ANALYSIS_MK.read_text() + + +def target_sources(): + """{target: source path} for every VERIFY__SRC, target lowercased. + + The name class matches what make accepts: its own list comes from + $(patsubst VERIFY_%_SRC,%,...), and % spans digits and underscores too. A + narrower pattern here would drop such a target silently, taking its source + and its mutations out of every consumer while make still proved it. + """ + return { + m.group(1).lower(): m.group(2) + for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text(), + re.M) + } + + +def targets(): + """The proof target names, lowercased.""" + return set(target_sources()) + + +def sources(): + """The proved source paths, without their target names.""" + return set(target_sources().values()) diff --git a/scripts/check-char-signedness.py b/scripts/check-char-signedness.py index f683c6ef..4544cbf2 100755 --- a/scripts/check-char-signedness.py +++ b/scripts/check-char-signedness.py @@ -62,10 +62,10 @@ def proof_sources(): srcs = { m.group(1).lower(): m.group(2).strip() - for m in re.finditer(r"^VERIFY_([A-Z]+)_SRC\s*:=\s*(\S+)", text, re.MULTILINE) + for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text, re.MULTILINE) } out = {} - for m in re.finditer(r"^VERIFY_([A-Z]+)_FCTS\s*:=\s*(.*)$", text, re.MULTILINE): + for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_FCTS\s*:=\s*(.*)$", text, re.MULTILINE): target = m.group(1).lower() if target == "utils" or target not in srcs: continue diff --git a/scripts/check-mutant-matrix-sync.py b/scripts/check-mutant-matrix-sync.py deleted file mode 100755 index 26ea2ea0..00000000 --- a/scripts/check-mutant-matrix-sync.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when the verify-mutants CI matrix drifts from mk/analysis.mk's targets. - -.github/workflows/main.yml's verify-mutants job hand-lists the same proof -target names mk/analysis.mk's VERIFY__SRC entries define, so the mutation -gate can shard one job per target. The workflow's own comment admits the -failure mode: a target missing from the YAML matrix silently drops that -target's mutation coverage from CI, with no error. The run still reports -green, having simply never mutated that target. Nothing else checks the two -lists stay in sync. - -Usage: - check-mutant-matrix-sync.py -""" - -import pathlib -import re -import sys - -ROOT = pathlib.Path(__file__).resolve().parent.parent - - -def mk_targets(): - """Proof target names from mk/analysis.mk's VERIFY__SRC entries.""" - text = (ROOT / "mk" / "analysis.mk").read_text() - return { - m.group(1).lower() - for m in re.finditer(r"^VERIFY_([A-Z]+)_SRC\s*:=", text, re.MULTILINE) - } - - -def workflow_matrix_targets(): - """Target names from verify-mutants' strategy.matrix.target list. - - Regex rather than a YAML parser, matching every other check script in - this tree that reads mk/analysis.mk or main.yml as text: PyYAML is not a - dependency anywhere else here, and the matrix block's shape (one - "- name" per line under a fixed "target:" key) does not need a real - parser to read reliably. - """ - text = (ROOT / ".github" / "workflows" / "main.yml").read_text() - m = re.search( - r"^ verify-mutants:.*?^ matrix:\n target:\n((?: - \S+\n)+)", - text, - re.MULTILINE | re.DOTALL, - ) - if not m: - print( - " could not find verify-mutants' matrix.target list in " - ".github/workflows/main.yml. The job may have been renamed or " - "restructured; update this script's regex to match", - file=sys.stderr, - ) - return None - - targets = re.findall(r"^ - (\S+)$", m.group(1), re.MULTILINE) - # Kept as a list until duplicates are checked: converting straight to a - # set here would make two matrix entries for the same target compare as - # "in sync" with mk/analysis.mk's single entry, silently accepting a - # hand-edit that doubles that target's mutation-gate job (and CI cost) - # rather than flagging it. - seen, dupes = set(), set() - for t in targets: - (dupes if t in seen else seen).add(t) - if dupes: - print( - " duplicate target(s) in verify-mutants' matrix.target list " - "in .github/workflows/main.yml. Each duplicate runs the same " - "target's mutation gate as a second, redundant CI job:", - file=sys.stderr, - ) - for t in sorted(dupes): - print(f" {t}", file=sys.stderr) - return None - return seen - - -def main(): - mk = mk_targets() - wf = workflow_matrix_targets() - if wf is None: - return 2 - - missing_from_ci = mk - wf - extra_in_ci = wf - mk - - if not missing_from_ci and not extra_in_ci: - print( - f" {len(mk)} proof target(s) match between mk/analysis.mk and " - "the verify-mutants CI matrix" - ) - return 0 - - if missing_from_ci: - print( - " proof target(s) in mk/analysis.mk with no verify-mutants CI " - "matrix entry, so their mutation coverage silently does not run " - "in CI:", - file=sys.stderr, - ) - for t in sorted(missing_from_ci): - print(f" {t}", file=sys.stderr) - if extra_in_ci: - print( - " verify-mutants CI matrix entries with no matching " - "VERIFY__SRC in mk/analysis.mk, likely a stale or " - "misspelled target:", - file=sys.stderr, - ) - for t in sorted(extra_in_ci): - print(f" {t}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/check-mutants.py b/scripts/check-mutants.py index cd16be09..4cbf027f 100755 --- a/scripts/check-mutants.py +++ b/scripts/check-mutants.py @@ -61,6 +61,22 @@ import tempfile ROOT = pathlib.Path(__file__).resolve().parent.parent + + +# scripts/ filenames are kebab-case per CLAUDE.md, which no plain "import" +# statement can name, so the shared reader is loaded by path. The alternative +# was an underscore in the filename, which the tree does not use anywhere. +def _load_analysis_mk(): + import importlib.util + + path = pathlib.Path(__file__).resolve().parent / "analysis-mk.py" + spec = importlib.util.spec_from_file_location("analysis_mk", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +analysis_mk_table = _load_analysis_mk() BUILD = ROOT / "build" / "mutants" # The recipe writes $(BUILD_DIR)/verify-$(NAME).log, and NAME is overridden per # run to keep concurrent mutations off a shared log. That path must exist first: @@ -73,10 +89,47 @@ # "function" names the proved function the mutation breaks; it is what the # coverage summary at the end counts, not decoration. MUTATIONS = [ + # ---- verify-align ------------------------------------------------------ + ( + "align", + "src/proved/align.h", + "align_up_ok", + "drop the overflow guard (the top multiple wraps to a lower address)", + " if (k >= UINT64_MAX / align)\n return 0;\n", + "", + ), + ( + "align", + "src/proved/align.h", + "align_up_ok", + "round down instead of up (the result can sit below the input)", + " if (k * align != x) {\n" + " if (k >= UINT64_MAX / align)\n" + " return 0;\n" + " k++;\n" + " }\n", + "", + ), + ( + "align", + "src/proved/align.h", + "window_fits", + "test the sum instead of the difference (the sum wraps first)", + " return start <= limit && length <= limit - start;", + " return start + length <= limit;", + ), + ( + "align", + "src/proved/align.h", + "window_fits", + "accept a window that overruns the limit by one", + " return start <= limit && length <= limit - start;", + " return start <= limit && length <= limit - start + 1;", + ), # ---- verify-cmsg ------------------------------------------------------- ( "cmsg", - "src/syscall/cmsg-math.h", + "src/proved/cmsg.h", "cmsg_entry_bounds", "drop the minimum-length guard (payload length underflows)", " if (cmsg_len < CMSG_LINUX_HDR_BYTES)\n return 0;\n", @@ -84,7 +137,7 @@ ), ( "cmsg", - "src/syscall/cmsg-math.h", + "src/proved/cmsg.h", "cmsg_entry_bounds", "drop the fits-in-buffer guard (payload runs past the control buffer)", " if (cmsg_len > ctl_len - pos)\n return 0;\n", @@ -92,7 +145,7 @@ ), ( "cmsg", - "src/syscall/cmsg-math.h", + "src/proved/cmsg.h", "cmsg_entry_bounds", "align up by ALIGN rather than ALIGN-1 (overshoots by one word)", " uint64_t advance = cmsg_len + (CMSG_LINUX_ALIGN - 1);", @@ -100,7 +153,7 @@ ), ( "cmsg", - "src/syscall/cmsg-math.h", + "src/proved/cmsg.h", "cmsg_entry_bounds", "drop the align-up entirely (walks to a misaligned next header)", " uint64_t advance = cmsg_len + (CMSG_LINUX_ALIGN - 1);\n" @@ -108,10 +161,47 @@ " *next_pos = pos + advance;", " *next_pos = pos + cmsg_len;", ), + ( + "cmsg", + "src/proved/cmsg.h", + "cmsg_entry_bounds", + "scribble on the outputs before rejecting the entry", + " if (cmsg_len < CMSG_LINUX_HDR_BYTES)\n return 0;\n", + " if (cmsg_len < CMSG_LINUX_HDR_BYTES) {\n" + " *data_len = 1;\n" + " return 0;\n" + " }\n", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_slot", + "drop the upper bound (the split indexes past the bitmap)", + " if (fd < 0 || fd >= FDSET_MAX_FDS)\n return 0;", + " if (fd < 0)\n return 0;", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_slot", + "accept the fd one past the table (off-by-one on the bound)", + " if (fd < 0 || fd >= FDSET_MAX_FDS)", + " if (fd < 0 || fd > FDSET_MAX_FDS)", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_slot", + "swap word and bit (the split no longer reconstructs the fd)", + " *word = (uint64_t) fd / FDSET_BITS_PER_WORD;\n" + " *bit = (uint64_t) fd % FDSET_BITS_PER_WORD;", + " *word = (uint64_t) fd % FDSET_BITS_PER_WORD;\n" + " *bit = (uint64_t) fd / FDSET_BITS_PER_WORD;", + ), # ---- verify-fuse ------------------------------------------------------- ( "fuse", - "src/syscall/fuse-math.h", + "src/proved/fuse.h", "fuse_reply_extent", "drop the header-size guard (reply length underflows)", " if (hdr_len < FUSE_OUT_HDR_BYTES || hdr_len > count)", @@ -119,7 +209,7 @@ ), ( "fuse", - "src/syscall/fuse-math.h", + "src/proved/fuse.h", "fuse_reply_extent", "drop the fits-the-write guard (copy runs past the frame)", " if (hdr_len < FUSE_OUT_HDR_BYTES || hdr_len > count)", @@ -127,7 +217,7 @@ ), ( "fuse", - "src/syscall/fuse-math.h", + "src/proved/fuse.h", "fuse_frame_count_ok", "drop the frame ceiling (unbounded daemon allocation)", " return count >= FUSE_OUT_HDR_BYTES && count <= FUSE_FRAME_CAP;", @@ -135,7 +225,7 @@ ), ( "fuse", - "src/syscall/fuse-math.h", + "src/proved/fuse.h", "fuse_clamp_negotiated_write", "make the negotiation clamp a no-op", " if (requested > FUSE_MAX_NEGOTIATED_WRITE)\n" @@ -145,7 +235,7 @@ ), ( "fuse", - "src/syscall/fuse-math.h", + "src/proved/fuse.h", "fuse_clamp_negotiated_write", "remove the header slack (a legal max_write no longer leaves room)", "#define FUSE_MAX_NEGOTIATED_WRITE (FUSE_FRAME_CAP - 256)", @@ -154,7 +244,7 @@ # ---- verify-gva -------------------------------------------------------- ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_leaf_target_args_ok", "predicate drops the granule upper bound", " return granule > 0 && granule <= GVA_PT_ADDR_MASK &&\n" @@ -163,7 +253,7 @@ ), ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_leaf_target_args_ok", "predicate drops the ipa bound", " return granule > 0 && granule <= GVA_PT_ADDR_MASK &&\n" @@ -172,7 +262,7 @@ ), ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_chunk_clamp_args_ok", "predicate drops the total < limit conjunct", " return chunk >= 1 && gpa < region_end && total < limit;", @@ -180,7 +270,7 @@ ), ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_chunk_clamp_args_ok", "predicate accepts everything", " return chunk >= 1 && gpa < region_end && total < limit;", @@ -188,7 +278,7 @@ ), ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_chunk_clamp", "call site permutes limit and total", " gva_chunk_clamp_args_ok(chunk, gpa, region_end, limit, total));", @@ -196,7 +286,7 @@ ), ( "gva", - "src/core/gva-math.h", + "src/proved/gva.h", "gva_leaf_target", "call site permutes granule and ipa", " GVA_CONTRACT_ASSERT(gva_leaf_target_args_ok(granule, ipa));", @@ -205,7 +295,7 @@ # ---- verify-stack ------------------------------------------------------ ( "stack", - "src/core/stack-math.h", + "src/proved/stack.h", "stack_take", "drop the floor check (descent leaves the stack region)", " if (bytes > *ptr - floor)\n return 0;\n\n *ptr -= bytes;", @@ -213,7 +303,7 @@ ), ( "stack", - "src/core/stack-math.h", + "src/proved/stack.h", "stack_take", "move before refusing (partial move on the reject path)", " if (bytes > *ptr - floor)\n return 0;\n", @@ -221,7 +311,7 @@ ), ( "stack", - "src/core/stack-math.h", + "src/proved/stack.h", "stack_align_down", "round up instead of down", " return sp - sp % STACK_ALIGN;", @@ -229,7 +319,7 @@ ), ( "stack", - "src/core/stack-math.h", + "src/proved/stack.h", "stack_pushed_words", "drop the alignment padding word", " return entries + entries % 2;", @@ -237,7 +327,7 @@ ), ( "stack", - "src/core/stack-math.h", + "src/proved/stack.h", "stack_final_sp", "drop the underflow guard (SP lands below the region)", " if (bytes > base - floor)\n return 0;\n\n *sp = base - bytes;", @@ -246,7 +336,7 @@ # ---- verify-sockaddr --------------------------------------------------- ( "sockaddr", - "src/syscall/sockaddr-math.h", + "src/proved/sockaddr.h", "sockaddr_payload_len", "drop the destination clamp (copy overruns the destination)", " if (payload > room)\n payload = room;\n", @@ -254,7 +344,7 @@ ), ( "sockaddr", - "src/syscall/sockaddr-math.h", + "src/proved/sockaddr.h", "sockaddr_len_ok", "accept addresses too short to hold a family", " return len >= SOCKADDR_FAMILY_BYTES;", @@ -262,7 +352,7 @@ ), ( "sockaddr", - "src/syscall/sockaddr-math.h", + "src/proved/sockaddr.h", "sockaddr_payload_len", "return no payload at all", " return payload;", @@ -270,16 +360,27 @@ ), ( "sockaddr", - "src/syscall/sockaddr-math.h", + "src/proved/sockaddr.h", "sockaddr_payload_len", "drop the source-length precondition", " requires src_len >= SOCKADDR_FAMILY_BYTES;\n", "", ), + ( + "fuse", + "src/proved/fuse.h", + "fuse_reply_extent", + "write the reply length before rejecting the header", + " if (hdr_len < FUSE_OUT_HDR_BYTES || hdr_len > count)\n return 0;", + " if (hdr_len < FUSE_OUT_HDR_BYTES || hdr_len > count) {\n" + " *reply_len = hdr_len;\n" + " return 0;\n" + " }", + ), # ---- verify-netlink ---------------------------------------------------- ( "netlink", - "src/syscall/netlink-math.h", + "src/proved/netlink.h", "netlink_rta_bounds", "drop the minimum-length guard (payload length underflows)", " if (rta_len < RTA_HDRLEN || rta_len > total - off)", @@ -287,7 +388,7 @@ ), ( "netlink", - "src/syscall/netlink-math.h", + "src/proved/netlink.h", "netlink_rta_bounds", "drop the fits-the-message guard (attribute runs past the message)", " if (rta_len < RTA_HDRLEN || rta_len > total - off)", @@ -295,7 +396,7 @@ ), ( "netlink", - "src/syscall/netlink-math.h", + "src/proved/netlink.h", "netlink_msg_span", "drop the header-length guard (span can be shorter than a header)", " if (nlmsg_len < NLMSG_HDRLEN)\n return 0;\n\n", @@ -303,7 +404,7 @@ ), ( "netlink", - "src/syscall/netlink-math.h", + "src/proved/netlink.h", "netlink_align_up", "round down instead of up (the walk stops advancing)", " uint64_t padded = len + (NETLINK_ALIGNTO - 1);", @@ -312,7 +413,7 @@ # ---- verify-sigframe --------------------------------------------------- ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "drop the underflow guard (frame base wraps to a huge address)", " if (frame_bytes > sp)\n return 0;\n", @@ -320,7 +421,7 @@ ), ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "drop the floor check (frame lands below the alternate stack)", " if (candidate < floor)\n return 0;\n", @@ -328,7 +429,7 @@ ), ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "skip the align-down (handler runs on a misaligned stack)", " candidate -= candidate % SIGFRAME_ALIGN;\n", @@ -336,7 +437,7 @@ ), ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "refuse a frame that exactly reaches the floor (off-by-one rejection)", " if (candidate < floor)", @@ -344,7 +445,7 @@ ), ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "place the frame one aligned slot lower than it must be", " *base = candidate;", @@ -352,7 +453,7 @@ ), ( "sigframe", - "src/syscall/sigframe-math.h", + "src/proved/sigframe.h", "sigframe_base", "place the frame at the floor rather than below sp", " *base = candidate;", @@ -375,6 +476,66 @@ " if (memsz > guest_size || gpa > guest_size - memsz)", " if (memsz > guest_size)", ), + ( + "gva", + "src/proved/gva.h", + "gva_pt_table_offset", + "write the offset before rejecting a descriptor below the base", + " uint64_t ipa = desc & GVA_PT_ADDR_MASK;\n if (ipa < base)\n return 0;", + " uint64_t ipa = desc & GVA_PT_ADDR_MASK;\n if (ipa < base) {\n" + " *off = ipa;\n return 0;\n }", + ), + ( + "gva", + "src/proved/gva.h", + "gva_pt_table_offset", + "write the offset before rejecting a table that runs past the slab", + " uint64_t candidate = ipa - base;\n if (guest_size < GVA_PT_TABLE_BYTES ||", + " uint64_t candidate = ipa - base;\n *off = candidate;\n" + " if (guest_size < GVA_PT_TABLE_BYTES ||", + ), + ( + "gva", + "src/proved/gva.h", + "gva_leaf_target", + "scribble on both outputs before rejecting an IPA below the base", + " if (ipa < base)\n return 0;\n\n uint64_t offset = gva % granule;", + " if (ipa < base) {\n *gpa = 0;\n *chunk = 1;\n" + " return 0;\n }\n\n uint64_t offset = gva % granule;", + ), + # ---- verify-pathdepth -------------------------------------------------- + ( + "pathdepth", + "src/proved/pathdepth.h", + "path_depth_pop", + "drop the root floor (the depth wraps and indexes marks[] wild)", + " if (depth == 0)\n return 0;\n", + "", + ), + ( + "pathdepth", + "src/proved/pathdepth.h", + "path_depth_pop", + "pop at the root too (a .. at / escapes one level)", + " if (depth == 0)", + " if (depth == UINT64_MAX)", + ), + ( + "pathdepth", + "src/proved/pathdepth.h", + "path_depth_push", + "drop the capacity check (the next mark write leaves the array)", + " if (depth >= cap)\n return 0;\n", + "", + ), + ( + "pathdepth", + "src/proved/pathdepth.h", + "path_depth_push", + "accept a push at capacity (off-by-one past the mark array)", + " if (depth >= cap)", + " if (depth > cap)", + ), # ---- verify-rsp -------------------------------------------------------- # # hex_nibble lives in src/utils.h, which verify-rsp includes rather than @@ -415,6 +576,269 @@ " sum += (uint8_t) data[i];", " sum += (unsigned int) data[i];", ), + # ---- verify-elf: the three helpers that had no mutation ---------------- + ( + "elf", + "src/core/elf.c", + "elf_add_no_wrap", + "drop the overflow guard (the sum wraps and is reported as valid)", + " if (a > UINT64_MAX - b)\n return 0;\n", + "", + ), + ( + "elf", + "src/core/elf.c", + "elf_phdr_gpa_in_segment", + "drop the fits-the-segment guard (the table runs past p_filesz)", + " if (rel > p_filesz || p_filesz - rel < total)\n return 0;", + " if (rel > p_filesz)\n return 0;", + ), + ( + "elf", + "src/core/elf.c", + "elf_phdr_gpa_in_segment", + "drop the below-segment guard (the relative offset underflows)", + " if (phoff < p_offset)\n return 0;\n", + "", + ), + ( + "elf", + "src/core/elf.c", + "elf_phdr_table_bytes", + "accept an entry stride below the header size (entries overlap)", + " if (phnum == 0 || phentsize < sizeof(elf64_phdr_t))", + " if (phnum == 0)", + ), + ( + "elf", + "src/core/elf.c", + "elf_phdr_table_bytes", + "drop the table ceiling (phnum * phentsize is unbounded)", + " if (bytes > ELF_PHDR_TABLE_MAX)\n return 0;\n", + "", + ), + ( + "gva", + "src/proved/gva.h", + "gva_span_ok", + "accept a zero-length span (callers treat the result as non-empty)", + " if (len == 0)\n return 0;\n", + "", + ), + ( + "gva", + "src/proved/gva.h", + "gva_span_ok", + "reject a span that exactly reaches the end of the address space", + " return gva <= UINT64_MAX - len;", + " return gva < UINT64_MAX - len;", + ), + ( + "rsp", + "src/debug/gdbstub-rsp.c", + "gdb_parse_hex", + "advance two characters per digit (steps over the NUL terminator)", + " val = val * 16u + (uint64_t) d;\n p++;", + " val = val * 16u + (uint64_t) d;\n p += 2;", + ), + # ---- verify-dirent ----------------------------------------------------- + ( + "dirent", + "src/proved/dirent.h", + "dirent_reclen", + "drop the align-up (records stop landing 8-byte aligned)", + " uint64_t padded = DIRENT64_HDR_BYTES + name_len + 1 + (DIRENT64_ALIGN - 1);", + " uint64_t padded = DIRENT64_HDR_BYTES + name_len + 1;", + ), + ( + "dirent", + "src/proved/dirent.h", + "dirent_reclen", + "forget the NUL byte (a NAME_MAX name loses its terminator)", + " uint64_t padded = DIRENT64_HDR_BYTES + name_len + 1 + (DIRENT64_ALIGN - 1);", + " uint64_t padded = DIRENT64_HDR_BYTES + name_len + (DIRENT64_ALIGN - 1);", + ), + ( + "dirent", + "src/proved/dirent.h", + "dirent_record_bounds", + "drop the fits-the-buffer guard (record runs past the guest count)", + " if (len > count - pos)\n return 0;\n\n", + "", + ), + ( + "dirent", + "src/proved/dirent.h", + "dirent_record_bounds", + "accept a record that overruns by one (off-by-one fit test)", + " if (len > count - pos)", + " if (len > count - pos + 1)", + ), + ( + "dirent", + "src/proved/dirent.h", + "dirent_record_bounds", + "start the padding one byte early (memset clobbers the NUL)", + " *pad_start = DIRENT64_HDR_BYTES + name_len + 1;", + " *pad_start = DIRENT64_HDR_BYTES + name_len;", + ), + # ---- verify-iov -------------------------------------------------------- + ( + "iov", + "src/proved/iov.h", + "iov_total_add", + "test the sum after adding rather than before (the add wraps first)", + " if (len > IOV_TOTAL_MAX - total)", + " if (total + len > IOV_TOTAL_MAX)", + ), + ( + "iov", + "src/proved/iov.h", + "iov_total_add", + "drop the overflow guard entirely", + " if (len > IOV_TOTAL_MAX - total)\n return 0;\n\n", + "", + ), + ( + "iov", + "src/proved/iov.h", + "iov_count_ok", + "accept iovcnt 0 (an empty vector reaches the per-entry loop)", + " return iovcnt >= 1 && iovcnt <= IOV_COUNT_MAX;", + " return iovcnt >= 0 && iovcnt <= IOV_COUNT_MAX;", + ), + ( + "iov", + "src/proved/iov.h", + "iov_count_ok", + "reject iovcnt at the cap (off-by-one rejection)", + " return iovcnt >= 1 && iovcnt <= IOV_COUNT_MAX;", + " return iovcnt >= 1 && iovcnt < IOV_COUNT_MAX;", + ), + # ---- verify-fdset ------------------------------------------------------ + ( + "fdset", + "src/proved/fdset.h", + "fdset_words", + "round the word count down (the last partial word is never read)", + " *words =\n" + " ((uint64_t) nfds + (FDSET_BITS_PER_WORD - 1)) / FDSET_BITS_PER_WORD;", + " *words = (uint64_t) nfds / FDSET_BITS_PER_WORD;", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_words", + "drop the upper bound on nfds (the read extent leaves the buffers)", + " if (nfds < 0 || nfds > FDSET_MAX_FDS)", + " if (nfds < 0)", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_fd_index", + "accept the bit one past nfds (polls an fd the caller did not ask for)", + " if (index >= (uint64_t) nfds)", + " if (index > (uint64_t) nfds)", + ), + ( + "fdset", + "src/proved/fdset.h", + "fdset_fd_index", + "drop the nfds bound (every bit of the last word is honored)", + " if (index >= (uint64_t) nfds)\n return 0;\n\n", + "", + ), + # ---- verify-timespec --------------------------------------------------- + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_ns_sat", + "drop the tv_sec ceiling (the product overflows int64_t)", + " if (sec > TIMESPEC_SEC_MAX)\n return INT64_MAX;\n", + "", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_ns_sat", + "drop the headroom check on the addition (a huge tv_nsec overflows)", + " if (nsec > INT64_MAX - whole)\n return INT64_MAX;\n", + "", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_ns_sat", + "accept the tv_sec one past the ceiling (off-by-one saturation)", + " if (sec > TIMESPEC_SEC_MAX)", + " if (sec > TIMESPEC_SEC_MAX + 1)", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_poll_ms", + "truncate the sub-millisecond remainder (a short wait becomes a spin)", + " if (ns % TIMESPEC_NSEC_PER_MSEC != 0)\n ms++;\n", + "", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_poll_ms", + "drop the int clamp (the ms value wraps negative, poll waits forever)", + " if (ms > INT32_MAX)\n return INT32_MAX;\n", + "", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_valid", + "accept a tv_nsec of exactly one second (off-by-one on the range)", + " return sec >= 0 && nsec >= 0 && nsec < TIMESPEC_NSEC_PER_SEC;", + " return sec >= 0 && nsec >= 0 && nsec <= TIMESPEC_NSEC_PER_SEC;", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_ns_sat", + "return 0 rather than saturating when tv_nsec overflows the sum", + " if (nsec > INT64_MAX - whole)\n return INT64_MAX;", + " if (nsec > INT64_MAX - whole)\n return 0;", + ), + ( + "timespec", + "src/proved/timespec.h", + "timespec_to_poll_ms", + "halve the timeout (poll returns before the caller asked)", + " int64_t ms = ns / TIMESPEC_NSEC_PER_MSEC;", + " int64_t ms = ns / (2 * TIMESPEC_NSEC_PER_MSEC);", + ), + # ---- verify-slice ------------------------------------------------------ + ( + "slice", + "src/proved/slice.h", + "slice_clamp", + "drop the end-of-buffer guard (the remaining count underflows)", + " if (offset >= src_len) {\n *n = 0;\n return 0;\n }\n\n", + "", + ), + ( + "slice", + "src/proved/slice.h", + "slice_clamp", + "serve the byte at the end offset (reads one past the buffer)", + " if (offset >= src_len) {", + " if (offset > src_len) {", + ), + ( + "slice", + "src/proved/slice.h", + "slice_clamp", + "return the requested count unclamped (copy runs past the end)", + " *n = count < avail ? count : avail;", + " *n = count;", + ), ] @@ -440,10 +864,11 @@ def analysis_mk(): # against whatever proof sources the same PR happens to touch, which is # nothing when the PR only edits CI. --target already narrows a full-set # fallback to one shard's own mutations (see the --changed-since block -# below), so this costs each of the nine shards its own subset, not all 40 +# below), so this costs each shard its own subset rather than all of them # apiece. HARNESS_FILES = { "scripts/check-mutants.py", + "scripts/analysis-mk.py", "scripts/check-wp-result.py", "scripts/check-acsl-coverage.py", "scripts/check-char-signedness.py", @@ -537,12 +962,21 @@ def target_inputs(cc): def target_sources(): """VERIFY__SRC for every target, as {target: path}.""" - return { - m.group(1).lower(): m.group(2) - for m in re.finditer( - r"^VERIFY_([A-Z]+)_SRC\s*:=\s*(\S+)", analysis_mk(), re.MULTILINE - ) - } + return analysis_mk_table.target_sources() + + +def target_mutable_files(): + """Files a target's mutation may edit, as {target: {paths}}. + + Only VERIFY__SRC, and that restriction is structural rather than + cautious: run_mutation copies one file and points the prover at the copy, + so a mutation to any other file leaves the proof reading the original + through -Isrc and produces a verdict about unmutated code. src/utils.h is + the case that comes up, since hex_nibble lives there and both verify-elf + and verify-rsp prove it; covering it by mutation would need a runner that + stages a whole tree. + """ + return {target: {src} for target, src in target_sources().items()} def proved_functions(): @@ -551,7 +985,7 @@ def proved_functions(): shared = re.search(r"^VERIFY_UTILS_FCTS\s*:=\s*(.*)$", text, re.MULTILINE) utils = shared.group(1) if shared else "" out = {} - for m in re.finditer(r"^VERIFY_([A-Z]+)_FCTS\s*:=\s*(.*)$", text, re.MULTILINE): + for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_FCTS\s*:=\s*(.*)$", text, re.MULTILINE): name = m.group(1).lower() if name == "utils": continue @@ -756,11 +1190,12 @@ def main(): # instead silently analyzes the wrong file: the run still produces a # verdict, and the verdict means nothing. sources = target_sources() + mutable = target_mutable_files() misdirected = { f"verify-{target}: mutates {src}, but VERIFY_{target.upper()}_SRC is " f"{sources.get(target, '')}" for target, src, *_rest in selected - if sources.get(target) != src + if src not in mutable.get(target, set()) } if misdirected: print( @@ -803,6 +1238,34 @@ def main(): pool.map(run_mutation, [i for i, _m in selected_pairs], selected) ) + # INFRA means the run produced no verdict, and by far its most common cause + # is prover starvation: several Frama-C processes, each with its own + # alt-ergo and z3, oversubscribe the machine and enough goals hit + # FRAMAC_TIMEOUT that the target exits without naming a reason. That is + # indistinguishable here from a genuinely broken mutation, and re-running + # the same mutation alone has resolved every occurrence seen so far. + # + # So re-run them once with the pool drained, one at a time. A load artifact + # turns into the verdict it should have had; a real failure stays INFRA and + # is reported. The retry is announced either way, because a gate that + # quietly re-rolls a failure until it passes is worse than one that flakes. + retried = [i for i, (status, _d) in enumerate(results) if status == "INFRA"] + if retried: + print( + f" {len(retried)} mutation(s) returned no verdict; re-running " + "them serially before scoring" + ) + for i in retried: + idx = selected_pairs[i][0] + before = results[i] + results[i] = run_mutation(idx, selected[i]) + target, _src, function = selected[i][:3] + if results[i][0] != before[0]: + print( + f" verify-{target}:{function}: {before[0]} under load, " + f"{results[i][0]} alone" + ) + failures = [] for mutation, (status, detail) in zip(selected, results): target, _src, function, desc = mutation[:4] diff --git a/scripts/check-proof-targets.py b/scripts/check-proof-targets.py new file mode 100755 index 00000000..d25be03e --- /dev/null +++ b/scripts/check-proof-targets.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Fail when the three lists naming proof targets drift apart. + +Three places name the same set of proved sources, and nothing but this script +keeps them in agreement: + + 1. mk/analysis.mk's VERIFY__SRC entries -- the targets themselves. + 2. .github/workflows/main.yml's verify-mutants matrix -- the CI sharding. + 3. src/proved/ -- the directory the proved headers live in. + +The third is the one the directory name rests on. src/proved/ claims its +contents are machine-checked, but a header dropped in there is proved only if +some verify- target names it: the Makefile drives the prover, not the +path. Without this check the directory could hold an unproved file and still +read as a guarantee, which is worse than no directory at all. + +.github/workflows/main.yml's verify-mutants job shards one runner per proof +target, and its matrix is fromJson of "make print-verify-targets" rather than +a list of its own. What is checked here is that it stays that way, and that +the list make generates is the whole list: a VERIFY__SRC block written +below the line that snapshots them is invisible to make and to CI while +still reading as a target in this file. + +Usage: + check-proof-targets.py +""" + +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +# scripts/ filenames are kebab-case per CLAUDE.md, which no plain "import" +# statement can name, so the shared reader is loaded by path. The alternative +# was an underscore in the filename, which the tree does not use anywhere. +def _load_analysis_mk(): + import importlib.util + + path = pathlib.Path(__file__).resolve().parent / "analysis-mk.py" + spec = importlib.util.spec_from_file_location("analysis_mk", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +analysis_mk = _load_analysis_mk() + + +PROVED_DIR = ROOT / "src" / "proved" + + +def proved_dir_sources(): + """Header paths under src/proved/, relative to the tree root.""" + return {f"src/proved/{p.name}" for p in sorted(PROVED_DIR.glob("*.h"))} + + +def tracked_sources(): + """Paths under src/proved/ that git has in the index.""" + out = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "src/proved"], + capture_output=True, + text=True, + ) + return set(out.stdout.split()) + + +def make_target_names(): + """The target names make actually generates rules for. + + Deliberately asks make rather than reading the file, because the two can + disagree. mk/analysis.mk derives its list with + "VERIFY_TARGETS := $(filter VERIFY_%_SRC,$(.VARIABLES))", and := is + immediate: .VARIABLES holds only what make has read so far, so a + VERIFY__SRC block written below that line is invisible to it. The block + parses, this script sees it, and make silently generates no rule and prints + no warning. That is the whole failure mode below. + """ + out = subprocess.run( + ["make", "-C", str(ROOT), "print-verify-targets"], + capture_output=True, + text=True, + ) + if out.returncode != 0: + print( + " 'make print-verify-targets' failed, so the target list CI " + f"builds its matrix from cannot be read:\n{out.stderr.strip()}", + file=sys.stderr, + ) + return None + return set(out.stdout.split()) + + +def workflow_matrix_is_derived(): + """Whether the verify-mutants matrix is built from mk/analysis.mk. + + It used to be a hand-kept copy of the target list and this function + compared the two. The copy is gone: a proof-targets job runs + "make print-verify-targets" and the matrix is fromJson of its output, so + the matrix cannot drift from what make generates. What is worth checking + now is that nobody has quietly gone back to a literal list, which would + restore the drift this script exists to prevent. + + That leaves one gap this cannot see, which make_target_names covers: the + matrix faithfully reproduces a target list that silently dropped a block. + """ + expected = "${{ fromJson(needs.proof-targets.outputs.targets) }}" + text = (ROOT / ".github" / "workflows" / "main.yml").read_text() + # [^\n]* rather than .*, because re.S would run the capture to the end of + # the file and accept a literal list here on the strength of an unrelated + # fromJson in a later job. + m = re.search(r"^ verify-mutants:.*?^ target:([^\n]*)$", text, re.M | re.S) + if not m: + print( + " could not find verify-mutants' matrix.target in " + ".github/workflows/main.yml; the job may have been renamed or " + "restructured, so update this check to match", + file=sys.stderr, + ) + return False + if m.group(1).strip() != expected: + shape = m.group(1).strip() or "a literal list on the following lines" + print( + " verify-mutants' matrix.target is not derived: " + f"{shape}\n" + f" It should be exactly {expected} so the target list has one " + "home in mk/analysis.mk. Another job's output would be derived " + "too, but from something this script does not read.", + file=sys.stderr, + ) + return False + return True + + +def main(): + mk = analysis_mk.targets() + if not workflow_matrix_is_derived(): + return 2 + + # The matrix being derived only helps if what it derives from is complete. + generated = make_target_names() + if generated is None: + return 2 + dropped = {t.lower() for t in mk} - generated + if dropped: + print( + " VERIFY__SRC block(s) in mk/analysis.mk that make generates " + "no rule for. Nothing fails today: the proof simply never runs, " + "here or in CI. Move the block above the 'VERIFY_TARGETS :=' " + "line, which snapshots the target list at the point it appears:", + file=sys.stderr, + ) + for t in sorted(dropped): + print(f" verify-{t}", file=sys.stderr) + return 1 + + # Both directions. A target naming a file that is not in the tree is the + # more damaging drift of the two: it survives locally, where the file + # exists but is untracked, and breaks every fresh clone and CI checkout. + missing_files = { + src + for src in analysis_mk.sources() + if src.startswith("src/proved/") and not (ROOT / src).exists() + } + if missing_files: + print( + " VERIFY__SRC entries in mk/analysis.mk naming a file that " + "does not exist. The build and the proofs reference it, so a " + "fresh clone fails even though this tree works:", + file=sys.stderr, + ) + for src in sorted(missing_files): + print(f" {src}", file=sys.stderr) + return 1 + + untracked = { + src + for src in analysis_mk.sources() + if src.startswith("src/proved/") and src not in tracked_sources() + } + if untracked: + print( + " VERIFY__SRC entries naming a file git does not track. It " + "exists here and nowhere else, which is the same failure one " + "commit later:", + file=sys.stderr, + ) + for src in sorted(untracked): + print(f" {src}", file=sys.stderr) + return 1 + + unproved = proved_dir_sources() - analysis_mk.sources() + if unproved: + print( + " file(s) under src/proved/ that no verify- target " + "proves. The directory name says otherwise, so either add a " + "VERIFY__SRC block in mk/analysis.mk or move the file out:", + file=sys.stderr, + ) + for f in sorted(unproved): + print(f" {f}", file=sys.stderr) + return 1 + + print( + f" {len(mk)} proof target(s) in mk/analysis.mk, all with a proved " + f"source; the CI matrix is derived from that list, and all " + f"{len(proved_dir_sources())} file(s) under src/proved/ are proved " + "by one" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core/guest.c b/src/core/guest.c index 4904429c..a7f76cde 100644 --- a/src/core/guest.c +++ b/src/core/guest.c @@ -40,7 +40,7 @@ #include #include "core/guest.h" -#include "core/gva-math.h" +#include "proved/gva.h" #include "core/startup-trace.h" #include "debug/log.h" #include "utils.h" diff --git a/src/core/stack.c b/src/core/stack.c index 61642d7f..6ab92785 100644 --- a/src/core/stack.c +++ b/src/core/stack.c @@ -16,7 +16,7 @@ #include #include -#include "core/stack-math.h" +#include "proved/stack.h" #include "core/stack.h" #include "debug/log.h" #include "syscall/linux-wire.h" /* GUEST_UID, GUEST_GID */ diff --git a/src/proved/align.h b/src/proved/align.h new file mode 100644 index 00000000..42608aea --- /dev/null +++ b/src/proved/align.h @@ -0,0 +1,113 @@ +/* + * Alignment and window arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The mmap gap finder walks the sorted region array rounding a candidate + * address past each region it collides with, and asks twice whether a length + * still fits before the end of the search window. Both operations are one + * expression, and both are wrong in the same way at the top of the address + * space: ALIGN_UP in src/utils.h is "(x + a - 1) & ~(a - 1)", which wraps to a + * small value rather than saturating, and a fits test written as "start + + * length <= limit" has already overflowed by the time it is read. + * + * A wrapped round-up in the gap finder is not a crash. It is an allocation + * whose start address sits below regions the walk already passed, so the guest + * gets a mapping overlapping one it already holds. Guest addresses are bounded + * by guest_size, at most 1 TiB, so neither wrap is reachable today: unreachable + * by provenance rather than by construction, which is the state this header + * exists to change. + * + * The align-up is written with division rather than the mask, so the proof does + * not first have to establish that the alignment is one less than a power of + * two, and so a caller passing a non-power-of-two alignment still gets a + * defined answer. That is not free here, and the comparison to + * src/proved/netlink.h does not carry: netlink's alignment is the literal 4, so + * the compiler folds the division away, while this one arrives as a parameter + * (a host page size or BLOCK_2MIB) and compiles to a real UDIV. The gap walk + * pays one per region it steps past, under mmap_lock, where ALIGN_UP cost two + * ALU ops. The trade is deliberate: the mask form makes "the result is at least + * x" a bitvector goal that neither prover here discharges, and an unproved wrap + * in this function is a guest mapping that overlaps one it already holds. If + * the walk ever shows up in an mmap profile, the fix is to prove the mask form + * under a power-of-two precondition, not to drop back to ALIGN_UP. + * + * Split into a header because mem.c cannot be given to Frama-C: it includes the + * macOS mman and HVF headers, which the analyzer's libc does not model. This + * header needs nothing but stdint.h, so make verify-align proves it directly. + */ + +#pragma once + +#include + +/* Round an address up to the next multiple of align, or 0 when that would carry + * past the end of the address space. + * + * Rejects exactly the inputs with no answer, which the pad-then-mask shape used + * elsewhere (netlink_align_up) does not: padding by align - 1 before testing + * refuses an address that is already a multiple and sits within align - 1 of + * UINT64_MAX, even though rounding it up is a no-op that cannot overflow. That + * takes a non-power-of-two align to reach, so the mmap gap finder never saw it, + * but a rejection wider than the arithmetic requires is the kind of thing a + * caller ends up encoding around. + */ +/*@ + requires align > 0; + requires \valid(out); + assigns *out; + ensures binary: \result == 0 || \result == 1; + ensures rejects_only_on_wrap: + \result != 0 <==> (x % align == 0 + || (x / align + 1) * align <= UINT64_MAX); + ensures aligned: + \result != 0 ==> (\exists integer k; *out == k * align); + ensures never_below: \result != 0 ==> *out >= x; + ensures rounds_up_once: \result != 0 ==> *out < x + align; + ensures untouched_on_reject: \result == 0 ==> *out == \old(*out); + */ +static inline int align_up_ok(uint64_t x, uint64_t align, uint64_t *out) +{ + uint64_t k = x / align; + + /* Counting in multiples rather than padding the address is what keeps the + * rejection exact. Padding by align - 1 first, then masking down, refuses + * an x that is already a multiple and sits within align - 1 of the top, + * because the pad overflows even though no rounding was needed. + * + * One assignment of the k * align form, so the alignment postcondition has + * its own witness. Stating it as "*out % align == 0" instead leaves a + * divisibility goal over a symbolic modulus, which neither alt-ergo nor z3 + * discharges in 30s; so does reaching the same value by adding align to a + * floor computed earlier, which needs distributivity the provers do not + * apply here. + */ + if (k * align != x) { + if (k >= UINT64_MAX / align) + return 0; + k++; + } + + *out = k * align; + return 1; +} + +/* Whether [start, start + length) fits below limit. + * + * The subtraction form is the point. "start + length <= limit" is the form that + * reads naturally and admits a length large enough to wrap the sum, which turns + * a rejected allocation into an accepted one that runs off the end of the + * window. + */ +/*@ + assigns \nothing; + ensures binary: \result == 0 || \result == 1; + ensures exact: \result != 0 <==> (start <= limit && length <= limit - start); + ensures fits: \result != 0 ==> start + length <= limit; + ensures no_wrap: \result != 0 ==> start + length >= start; + */ +static inline int window_fits(uint64_t start, uint64_t length, uint64_t limit) +{ + return start <= limit && length <= limit - start; +} diff --git a/src/syscall/cmsg-math.h b/src/proved/cmsg.h similarity index 91% rename from src/syscall/cmsg-math.h rename to src/proved/cmsg.h index 5812def4..aa2f4a0e 100644 --- a/src/syscall/cmsg-math.h +++ b/src/proved/cmsg.h @@ -65,6 +65,11 @@ * about, that its loop terminates and that its own bound check still rejects * the next position. Removing one drops the goal count below MIN_GOALS, so the * gate still notices if they go missing. + * + * The reject path leaves both outputs alone, stated as a postcondition because + * "assigns" permits writing them: without it a conforming implementation could + * scribble on them before returning 0, and a caller reading them on the failure + * path would be relying on the body rather than the contract. */ /*@ requires ctl_len <= CMSG_LINUX_CTL_MAX; @@ -85,6 +90,8 @@ *next_pos == pos + (cmsg_len + (CMSG_LINUX_ALIGN - 1)) - (cmsg_len + (CMSG_LINUX_ALIGN - 1)) % CMSG_LINUX_ALIGN; + ensures \result == 0 ==> *data_len == \old(*data_len); + ensures \result == 0 ==> *next_pos == \old(*next_pos); */ static inline int cmsg_entry_bounds(uint64_t pos, uint64_t ctl_len, @@ -103,7 +110,7 @@ static inline int cmsg_entry_bounds(uint64_t pos, * usual "(len + 7) & ~7": the compiler emits the same instruction, but the * prover would first have to establish that the mask is one less than a * power of two, and leaves the two next_pos bounds open when it cannot. - * Same reason src/core/gva-math.h uses "% granule". + * Same reason src/proved/gva.h uses "% granule". */ uint64_t advance = cmsg_len + (CMSG_LINUX_ALIGN - 1); advance -= advance % CMSG_LINUX_ALIGN; diff --git a/src/proved/dirent.h b/src/proved/dirent.h new file mode 100644 index 00000000..b6f89176 --- /dev/null +++ b/src/proved/dirent.h @@ -0,0 +1,148 @@ +/* + * getdents64 record arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Two directory readers pack Linux dirent64 records into a guest buffer: the + * host-directory walk in fs.c and the FUSE reply walk in fuse.c. Both size each + * record from a name length, both pad it to 8, and both must land the record + * inside a guest-supplied byte count and inside a fixed-size staging buffer on + * the C stack. Getting either bound wrong overruns a 280-byte stack array with + * attacker-influenced bytes, so the record arithmetic is discharged as a + * machine-checked proof rather than reviewed by eye. + * + * The two copies had drifted: fuse.c bounds the daemon-supplied name length + * against NAME_MAX and then re-checks the record against sizeof(entry), while + * fs.c relied on its NAME_MAX + 1 translation buffer to make the same check + * unnecessary. One proved function now serves both, and the "fits the staging + * buffer" half stops being a check at all: it is a postcondition. + * + * Split out of fs.c because that file cannot be given to Frama-C: it includes + * the macOS dirent and stat headers, which the analyzer's libc does not model. + * This header needs nothing but stdint.h, so make verify-dirent proves it + * directly. + */ + +#pragma once + +#include + +/* Bytes before the name in a Linux dirent64: d_ino(8) + d_off(8) + d_reclen(2) + * + d_type(1). Not sizeof() a struct: the wire record has no padding before the + * name, while a C struct with those members would be padded to 24 by uint64_t + * alignment. Both callers therefore memcpy a header struct and then write the + * name at this offset, so the constant is the layout. + */ +#define DIRENT64_HDR_BYTES 19ULL + +/* Linux pads each record so the next one starts 8-byte aligned. */ +#define DIRENT64_ALIGN 8ULL + +/* Linux NAME_MAX. Named here rather than taken from the host limits.h: this is + * the guest's limit, and the proof is about what a guest dirent can hold. + */ +#define DIRENT64_NAME_MAX 255ULL + +/* Capacity both callers' staging buffers must have. dirent_record_bounds + * guarantees the record fits it, which is why neither caller checks. + */ +#define DIRENT64_MAX_RECLEN 280ULL + +_Static_assert(DIRENT64_MAX_RECLEN == (DIRENT64_HDR_BYTES + DIRENT64_NAME_MAX + + 1 + (DIRENT64_ALIGN - 1)) / + DIRENT64_ALIGN * DIRENT64_ALIGN, + "DIRENT64_MAX_RECLEN must be the padded size of a NAME_MAX " + "entry, or the staging buffers it sizes are too small"); +_Static_assert(DIRENT64_ALIGN == 8ULL, + "Linux dirent64 records are 8-byte aligned; the proofs adapt " + "but the guest ABI does not"); +_Static_assert(DIRENT64_HDR_BYTES == 8 + 8 + 2 + 1, + "d_ino + d_off + d_reclen + d_type, unpadded"); + +/* The padded record size, as a logic term. ACSL cannot call a C function, and + * dirent_record_bounds' contract has to say which size it computed, so the + * arithmetic is written once here and both contracts refer to it. Defined + * rather than axiomatized: a definition unfolds, so nothing here is assumed. + */ +/*@ + logic integer dirent_reclen_of(integer name_len) = + (DIRENT64_HDR_BYTES + name_len + 1 + (DIRENT64_ALIGN - 1)) - + (DIRENT64_HDR_BYTES + name_len + 1 + (DIRENT64_ALIGN - 1)) % + DIRENT64_ALIGN; + */ + +/* Size of the record holding a name of name_len bytes, padded to alignment. + * + * Written as subtract-the-remainder rather than "(n + 7) & ~7": the compiler + * emits the same instruction, and the prover reasons about the arithmetic form + * without first establishing that the mask is one less than a power of two. + * Same reason src/proved/netlink.h uses "% NETLINK_ALIGNTO". + * + * The upper bound on the result is the clause that matters: it is what lets + * both callers stage a record in a fixed 280-byte array with no bounds check of + * their own. + */ +/*@ + requires name_len <= DIRENT64_NAME_MAX; + assigns \nothing; + ensures \result == dirent_reclen_of(name_len); + ensures \result % DIRENT64_ALIGN == 0; + ensures \result >= DIRENT64_HDR_BYTES + name_len + 1; + ensures \result < DIRENT64_HDR_BYTES + name_len + 1 + DIRENT64_ALIGN; + ensures \result <= DIRENT64_MAX_RECLEN; + ensures \result > 0; + */ +static inline uint64_t dirent_reclen(uint64_t name_len) +{ + uint64_t padded = DIRENT64_HDR_BYTES + name_len + 1 + (DIRENT64_ALIGN - 1); + return padded - padded % DIRENT64_ALIGN; +} + +/* Record size and start of the padding for one entry, or 0 when the entry does + * not fit the remaining guest buffer. + * + * pos is where the record would start, count is the buffer the guest passed to + * getdents64. The subtraction form of the fit test is deliberate: "pos + reclen + * > count" is the form both callers used, and it is only safe because reclen is + * small and pos never exceeds count. Stating it as "reclen <= count - pos" + * under a proved "pos <= count" keeps that reasoning out of the caller. + * + * pad_start is an output rather than a caller expression because it is the + * memset extent: the callers zero [pad_start, reclen), and *pad_start <= + * *reclen is what makes that length non-negative. + */ +/*@ + requires name_len <= DIRENT64_NAME_MAX; + requires pos <= count; + requires \valid(reclen); + requires \valid(pad_start); + requires \separated(reclen, pad_start); + assigns *reclen, *pad_start; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> dirent_reclen_of(name_len) <= count - pos; + ensures \result != 0 ==> *reclen == dirent_reclen_of(name_len); + ensures \result != 0 ==> *reclen <= DIRENT64_MAX_RECLEN; + ensures \result != 0 ==> *reclen > 0; + ensures \result != 0 ==> *reclen % DIRENT64_ALIGN == 0; + ensures \result != 0 ==> pos + *reclen <= count; + ensures \result != 0 ==> *pad_start == DIRENT64_HDR_BYTES + name_len + 1; + ensures \result != 0 ==> *pad_start <= *reclen; + ensures \result == 0 ==> *reclen == \old(*reclen); + ensures \result == 0 ==> *pad_start == \old(*pad_start); + */ +static inline int dirent_record_bounds(uint64_t name_len, + uint64_t pos, + uint64_t count, + uint64_t *reclen, + uint64_t *pad_start) +{ + uint64_t len = dirent_reclen(name_len); + + if (len > count - pos) + return 0; + + *reclen = len; + *pad_start = DIRENT64_HDR_BYTES + name_len + 1; + return 1; +} diff --git a/src/proved/fdset.h b/src/proved/fdset.h new file mode 100644 index 00000000..73282e86 --- /dev/null +++ b/src/proved/fdset.h @@ -0,0 +1,176 @@ +/* + * fd bitmap word arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * pselect6 reads three guest bitmasks into fixed-size stack arrays and then + * walks them a 64-bit word at a time. Two things there are worth proving rather + * than reviewing. + * + * The first is the read extent. The arrays are sized from FD_TABLE_SIZE while + * the nfds argument was bounded by the host's FD_SETSIZE, two constants that + * happen to both be 1024 on macOS but are not the same constant. Nothing tied + * them together, so a host whose FD_SETSIZE was larger would have made + * guest_read_small write past three stack arrays with guest bytes. + * + * Bounding the extent is not the same as matching Linux on what nfds is legal. + * elfuse rejects nfds above the table with EINVAL where core_sys_select clamps + * and proceeds; that divergence predates this header and is unchanged by it. A + * naive clamp would be wrong here, because the result writeback copies only + * nfds_words * 8 bytes and would leave the guest's upper fd_set words unzeroed, + * which Linux does clear. + * + * The second is the tail of the last word. nfds need not be a multiple of 64, + * and the walk iterated whole words, so bits above nfds in the final word were + * honored: a guest that set bit 100 with nfds=70 got that fd polled, and got + * EBADF if it was not open, where Linux ignores it (fs/select.c bounds its + * per-word iteration by n). fdset_fd_index answers that per set bit. Masking + * each word once on the way in would read better at the call site, and was the + * first draft, but its postcondition is a symbolic shift that no prover here + * discharges; see the note on fdset_fd_index below. Nothing hands back a + * pre-masked word, so every consumer of these bitmasks has to filter its bits + * through fdset_fd_index. + * + * Several bitmaps in the tree run 64 bits to a word over the same fd table: + * pselect6's three guest bitmasks in poll.c, the free-fd allocator's + * fd_free_bitmap in fdtable.c, and the urandom bitmap in shim-globals.c. They + * share the constants here, but not every one of them should route its + * indexing through the helpers below. + * + * The bound belongs here when it is a fact about the input: pselect6's nfds + * arrives from the guest, so the reject branch is a branch the code has to take + * anyway. It does not belong here when the caller has already established the + * bound, because then the reject branch is unreachable, and an unreachable + * branch is invisible to a reader while being load-bearing to an analyzer. That + * is not hypothetical: guarding fdtable.c's one-line bitmap setters this way + * left the fd_table[fd] write beside them unguarded, and Infer's Pulse then + * concluded a socket fd escaped nowhere and reported a leak in net.c, two + * modules from the change. Those setters index directly again. + * + * Split into a header because poll.c cannot be given to Frama-C: it includes + * the macOS poll and select headers, which the analyzer's libc does not model. + * This header needs nothing but stdint.h, so make verify-fdset proves it + * directly. + */ + +#pragma once + +#include + +#define FDSET_BITS_PER_WORD 64ULL + +/* The largest nfds accepted, and the array sizing it implies. poll.c static + * asserts FDSET_MAX_FDS against FD_TABLE_SIZE, which is what stops the two from + * drifting apart again. + */ +#define FDSET_MAX_FDS 1024LL +#define FDSET_MAX_WORDS 16ULL +#define FDSET_MAX_BYTES 128ULL + +_Static_assert(FDSET_MAX_WORDS == + (uint64_t) FDSET_MAX_FDS / FDSET_BITS_PER_WORD, + "the word count must cover exactly FDSET_MAX_FDS bits"); +_Static_assert(FDSET_MAX_BYTES == FDSET_MAX_WORDS * 8, + "the byte count is what guest_read_small copies"); + +/* The word and bit holding one fd, or 0 when the fd is outside the table. + * + * Use this where the rejection is a real case rather than a restatement of what + * the caller already knows. Its one caller qualifies: fd_bitmap_find_free takes + * minfd from fcntl(F_DUPFD), which forwards the guest's argument having + * rejected only negatives. *word < FDSET_MAX_WORDS is then a postcondition, so + * the bitmap access that follows needs no bound of its own. + */ +/*@ + requires \valid(word); + requires \valid(bit); + requires \separated(word, bit); + assigns *word, *bit; + ensures binary: \result == 0 || \result == 1; + ensures exact: \result != 0 <==> (0 <= fd < FDSET_MAX_FDS); + ensures word_in_table: \result != 0 ==> *word < FDSET_MAX_WORDS; + ensures bit_in_word: \result != 0 ==> *bit < FDSET_BITS_PER_WORD; + ensures splits_fd: + \result != 0 ==> *word * FDSET_BITS_PER_WORD + *bit == fd; + ensures untouched_on_reject: \result == 0 ==> *word == \old(*word); + ensures bit_untouched_on_reject: \result == 0 ==> *bit == \old(*bit); + */ +static inline int fdset_slot(int64_t fd, uint64_t *word, uint64_t *bit) +{ + if (fd < 0 || fd >= FDSET_MAX_FDS) + return 0; + + *word = (uint64_t) fd / FDSET_BITS_PER_WORD; + *bit = (uint64_t) fd % FDSET_BITS_PER_WORD; + return 1; +} + +/* Words spanning nfds bits, or 0 when nfds is out of range. + * + * The result bounds the read extent, so the caller needs no size check of its + * own: *words <= FDSET_MAX_WORDS is a postcondition, not a review note. + */ +/*@ + requires \valid(words); + assigns *words; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> (0 <= nfds <= FDSET_MAX_FDS); + ensures \result != 0 ==> *words <= FDSET_MAX_WORDS; + ensures \result != 0 ==> *words * 8 <= FDSET_MAX_BYTES; + ensures \result != 0 ==> *words * FDSET_BITS_PER_WORD >= nfds; + ensures \result != 0 ==> *words * FDSET_BITS_PER_WORD < + nfds + FDSET_BITS_PER_WORD; + ensures \result != 0 ==> (*words == 0 <==> nfds == 0); + ensures \result == 0 ==> *words == \old(*words); + */ +static inline int fdset_words(int64_t nfds, uint64_t *words) +{ + if (nfds < 0 || nfds > FDSET_MAX_FDS) + return 0; + + *words = + ((uint64_t) nfds + (FDSET_BITS_PER_WORD - 1)) / FDSET_BITS_PER_WORD; + return 1; +} + +/* The fd a set bit names, or 0 when that bit sits above nfds. + * + * Called once per set bit, so the "walk whole words but honor only the bits + * below nfds" rule lives in one place instead of in the loop's index + * arithmetic. Returning the index rather than a yes/no is what makes the fd + * bound a postcondition: *fd < nfds <= FDSET_MAX_FDS is what the caller needs + * before it indexes the fd table with it. + * + * An earlier draft returned a per-word mask of valid bits instead, which reads + * better at the call site but states its postcondition as "the low (nfds % 64) + * bits are set". That is a symbolic shift, and both provers time out on it at + * 60s. The property that matters here is the fd bound, and this form proves it + * in milliseconds; a spec no prover discharges is not a spec. + */ +/*@ + requires 0 <= nfds <= FDSET_MAX_FDS; + requires word < FDSET_MAX_WORDS; + requires bit_index < FDSET_BITS_PER_WORD; + requires \valid(fd); + assigns *fd; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> word * FDSET_BITS_PER_WORD + bit_index < nfds; + ensures \result != 0 ==> *fd == word * FDSET_BITS_PER_WORD + bit_index; + ensures \result != 0 ==> *fd < nfds; + ensures \result != 0 ==> *fd < FDSET_MAX_FDS; + ensures \result == 0 ==> *fd == \old(*fd); + */ +static inline int fdset_fd_index(int64_t nfds, + uint64_t word, + uint64_t bit_index, + uint64_t *fd) +{ + uint64_t index = word * FDSET_BITS_PER_WORD + bit_index; + + if (index >= (uint64_t) nfds) + return 0; + + *fd = index; + return 1; +} diff --git a/src/syscall/fuse-math.h b/src/proved/fuse.h similarity index 93% rename from src/syscall/fuse-math.h rename to src/proved/fuse.h index 6badc48c..02c68744 100644 --- a/src/syscall/fuse-math.h +++ b/src/proved/fuse.h @@ -77,6 +77,11 @@ static inline int fuse_frame_count_ok(uint64_t count) * FUSE_OUT_HDR_BYTES out of a buffer holding exactly count bytes. Dropping * either half of the guard breaks it, the lower half by underflowing the * subtraction and the upper half by reading past the frame. + * + * The reject path leaves the output alone, stated as a postcondition because + * "assigns" permits writing them: without it a conforming implementation could + * scribble on it before returning 0, and a caller reading it on the failure + * path would be relying on the body rather than the contract. */ /*@ requires FUSE_OUT_HDR_BYTES <= count <= FUSE_FRAME_CAP; @@ -87,6 +92,7 @@ static inline int fuse_frame_count_ok(uint64_t count) ensures \result != 0 ==> *reply_len == hdr_len - FUSE_OUT_HDR_BYTES; ensures \result != 0 ==> FUSE_OUT_HDR_BYTES + *reply_len <= count; ensures \result != 0 ==> *reply_len <= FUSE_FRAME_CAP - FUSE_OUT_HDR_BYTES; + ensures \result == 0 ==> *reply_len == \old(*reply_len); */ static inline int fuse_reply_extent(uint64_t count, uint64_t hdr_len, diff --git a/src/core/gva-math.h b/src/proved/gva.h similarity index 96% rename from src/core/gva-math.h rename to src/proved/gva.h index c0ff3c59..6193dffb 100644 --- a/src/core/gva-math.h +++ b/src/proved/gva.h @@ -92,6 +92,12 @@ * and the walker indexes all 512 of them, so the whole table must fit, not * merely its first byte. "*off < guest_size" would be satisfied by off == * guest_size - 8, which puts l1[511] past the end of the slab. + * + * The reject path leaves off alone, stated as a postcondition because + * "assigns" permits writing it: without that clause a conforming + * implementation could scribble on it before returning 0, and a caller reading + * it on the failure path would be relying on the body rather than the + * contract. */ #define GVA_PT_TABLE_BYTES 4096ULL @@ -105,6 +111,7 @@ ((desc & GVA_PT_ADDR_MASK) >= base && (desc & GVA_PT_ADDR_MASK) - base + GVA_PT_TABLE_BYTES <= guest_size); + ensures \result == 0 ==> *off == \old(*off); */ static inline int gva_pt_table_offset(uint64_t desc, uint64_t base, @@ -162,6 +169,8 @@ static inline int gva_leaf_target_args_ok(uint64_t granule, uint64_t ipa) ensures \result != 0 ==> 1 <= *chunk <= granule; ensures \result != 0 ==> *chunk == granule - gva % granule; ensures \result != 0 ==> *gpa == ipa - base + gva % granule; + ensures \result == 0 ==> *gpa == \old(*gpa); + ensures \result == 0 ==> *chunk == \old(*chunk); */ static inline int gva_leaf_target(uint64_t ipa, uint64_t base, diff --git a/src/proved/iov.h b/src/proved/iov.h new file mode 100644 index 00000000..3c270160 --- /dev/null +++ b/src/proved/iov.h @@ -0,0 +1,87 @@ +/* + * iovec accumulation arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * readv, writev, preadv, pwritev, recvmsg and sendmsg all take an iovec array + * the guest wrote, and every one of them sums iov_len across it. Linux returns + * EINVAL when that sum exceeds SSIZE_MAX; elfuse has to do the same, because + * the sum reaches a malloc extent and a memcpy loop on the /proc + * write-intercept path. A wrapped sum there is a small allocation followed by a + * large copy. + * + * io.c carried four copies of the accumulation with three different guards, and + * proc_try_writev_intercept had none at all: it summed straight into malloc(). + * That one was unreachable in practice, since host_iov_prepare clamps each + * entry to the guest mapping it points into, but unreachable by provenance + * rather than by construction. One proved add now serves every site: + * urandom_fill_iov, validate_iov_total, proc_try_writev_intercept and + * process_vm_import_iov. + * + * Split into a header because io.c and net-msg.c cannot be given to Frama-C: + * they include the macOS uio and socket headers, which the analyzer's libc does + * not model. This header needs nothing but stdint.h, so make verify-iov proves + * it directly. + */ + +#pragma once + +#include + +/* Linux UIO_MAXIOV: the cap on iovcnt every one of these syscalls enforces. */ +#define IOV_COUNT_MAX 1024LL + +/* SSIZE_MAX on LP64, spelled out rather than included: this is the guest's + * ssize_t, and the value Linux compares the running total against. + */ +#define IOV_TOTAL_MAX 0x7FFFFFFFFFFFFFFFULL + +_Static_assert(IOV_TOTAL_MAX == (uint64_t) INT64_MAX, + "the total cap is the guest's SSIZE_MAX"); + +/* Whether an iovec count is one Linux would accept. + * + * Takes int64_t rather than int so a caller holding a wider count can pass it + * without narrowing first. Only the readv family uses it today, and with an + * int. sendmsg and recvmsg keep their own msg_iovlen cap and have to: they + * accept msg_iovlen == 0, which this check rejects, so it cannot serve them. + */ +/*@ + assigns \nothing; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> (1 <= iovcnt <= IOV_COUNT_MAX); + */ +static inline int iov_count_ok(int64_t iovcnt) +{ + return iovcnt >= 1 && iovcnt <= IOV_COUNT_MAX; +} + +/* Add one entry's length to a running total, or 0 when that would carry the + * total past SSIZE_MAX. + * + * The guard is written as "len > IOV_TOTAL_MAX - total" rather than "total + + * len > IOV_TOTAL_MAX" for the obvious reason: the second form has already + * overflowed by the time it is tested. That the two are not equivalent is + * exactly what a reviewer skims past, so it is stated as a postcondition + * instead. + */ +/*@ + requires total <= IOV_TOTAL_MAX; + requires \valid(out); + assigns *out; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> len <= IOV_TOTAL_MAX - total; + ensures \result != 0 ==> *out == total + len; + ensures \result != 0 ==> *out <= IOV_TOTAL_MAX; + ensures \result != 0 ==> *out >= total; + ensures \result == 0 ==> *out == \old(*out); + */ +static inline int iov_total_add(uint64_t total, uint64_t len, uint64_t *out) +{ + if (len > IOV_TOTAL_MAX - total) + return 0; + + *out = total + len; + return 1; +} diff --git a/src/syscall/netlink-math.h b/src/proved/netlink.h similarity index 99% rename from src/syscall/netlink-math.h rename to src/proved/netlink.h index 1ab753c6..4778ed00 100644 --- a/src/syscall/netlink-math.h +++ b/src/proved/netlink.h @@ -64,7 +64,7 @@ _Static_assert(NLMSG_HDRLEN % NETLINK_ALIGNTO == 0, * Written as subtract-the-remainder rather than "(len + 3) & ~3": the compiler * emits the same instruction, and the prover reasons about the arithmetic form * without first establishing that the mask is one less than a power of two. - * Same reason src/core/gva-math.h uses "% granule". + * Same reason src/proved/gva.h uses "% granule". */ /*@ requires len <= NETLINK_LEN_MAX; diff --git a/src/proved/pathdepth.h b/src/proved/pathdepth.h new file mode 100644 index 00000000..6bbbe5ef --- /dev/null +++ b/src/proved/pathdepth.h @@ -0,0 +1,87 @@ +/* + * Path component depth arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Sysroot containment is a counting argument. Resolution walks a guest path + * component by component, pushing a mark for each name and popping one for each + * "..", and the guest cannot escape the sysroot precisely because the pop + * floors at zero the way path_resolution(7) floors "/.." at the root. Three + * loops in path.c keep that counter, each spelling the floor differently, and + * one of them uses it to index the mark array that records where each component + * started. + * + * Both directions are one comparison away from a memory error. Drop the floor + * and "depth--" at zero wraps to SIZE_MAX, so the next pop reads marks at that + * index and truncates the output buffer at whatever it finds; drop the capacity + * check and the next push writes marks one past its end. Neither is reachable + * today, and neither is reachable by construction, which is the distinction + * this header exists to close on the boundary that keeps a guest inside its + * sysroot. + * + * Split into a header because path.c cannot be given to Frama-C: it includes + * the macOS dirent and fcntl headers, which the analyzer's libc does not model. + * This header needs nothing but stdint.h, so make verify-pathdepth proves it + * directly. + */ + +#pragma once + +#include + +/* Record one more component, or 0 when the mark array is full. + * + * The capacity is a parameter rather than a constant here because the callers + * size their mark arrays differently. What the proof pins is that a successful + * push leaves a depth that is a valid index for the NEXT mark write, which is + * the property the caller would otherwise have to re-derive at the array. + */ +/*@ + requires depth <= cap; + requires \valid(out_depth); + assigns *out_depth; + ensures binary: \result == 0 || \result == 1; + ensures exact: \result != 0 <==> depth < cap; + ensures counts_one: \result != 0 ==> *out_depth == depth + 1; + ensures stays_in_array: \result != 0 ==> *out_depth <= cap; + ensures no_wrap: \result != 0 ==> *out_depth > depth; + ensures untouched_on_reject: \result == 0 ==> *out_depth == \old(*out_depth); + */ +static inline int path_depth_push(uint64_t depth, + uint64_t cap, + uint64_t *out_depth) +{ + if (depth >= cap) + return 0; + + *out_depth = depth + 1; + return 1; +} + +/* Drop one component for a "..", or 0 at the root. + * + * Returning 0 rather than saturating is what lets the caller distinguish the + * two cases path_resolution(7) separates: a ".." that pops a real component, + * and a ".." at the root that names the root and must not touch the output. The + * callers that only need the floor ignore the distinction and treat 0 as "leave + * the depth alone". + */ +/*@ + requires \valid(out_depth); + assigns *out_depth; + ensures binary: \result == 0 || \result == 1; + ensures exact: \result != 0 <==> depth > 0; + ensures counts_one: \result != 0 ==> *out_depth == depth - 1; + ensures no_wrap: \result != 0 ==> *out_depth < depth; + ensures floors_at_root: \result == 0 <==> depth == 0; + ensures untouched_at_root: \result == 0 ==> *out_depth == \old(*out_depth); + */ +static inline int path_depth_pop(uint64_t depth, uint64_t *out_depth) +{ + if (depth == 0) + return 0; + + *out_depth = depth - 1; + return 1; +} diff --git a/src/syscall/sigframe-math.h b/src/proved/sigframe.h similarity index 98% rename from src/syscall/sigframe-math.h rename to src/proved/sigframe.h index cb84b1b7..c6422260 100644 --- a/src/syscall/sigframe-math.h +++ b/src/proved/sigframe.h @@ -80,7 +80,7 @@ static inline int sigframe_base(uint64_t sp, /* Align down, written as subtract-the-remainder rather than "& ~15": the * compiler emits the same instruction, and the prover reasons about the * arithmetic form without first establishing that the mask is one less than - * a power of two. Same reason src/core/gva-math.h uses "% granule". + * a power of two. Same reason src/proved/gva.h uses "% granule". */ uint64_t candidate = sp - frame_bytes; candidate -= candidate % SIGFRAME_ALIGN; diff --git a/src/proved/slice.h b/src/proved/slice.h new file mode 100644 index 00000000..b40b3dfa --- /dev/null +++ b/src/proved/slice.h @@ -0,0 +1,65 @@ +/* + * Read-window arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Several reads answer from a buffer elfuse synthesized rather than from a host + * fd: the /proc/self/oom_* nodes format a value into a stack array and then + * serve pread and preadv against it, and fallocate's punch-hole fallback writes + * zeros over the part of a file that exists. All of them take a guest-supplied + * offset and count and turn them into a memcpy extent, which is the shape that + * reads past the end of a 32-byte stack array when the EOF test is wrong. + * + * Three copies of the clamp existed, each spelled slightly differently, and the + * scalar and iovec variants in procemu.c had drifted into different loop + * shapes. One proved function serves all of them. + * + * Split into a header because procemu.c and io.c cannot be given to Frama-C: + * they include the macOS uio and fcntl headers, which the analyzer's libc does + * not model. This header needs nothing but stdint.h, so make verify-slice + * proves it directly. + */ + +#pragma once + +#include + +/* Bytes readable at offset, or 0 when the offset is at or past the end. + * + * The return value distinguishes "nothing left" (0, the caller reports EOF) + * from "here is a window" (1). The subtraction that computes what remains runs + * only under the proved offset < src_len, which is what keeps it from + * underflowing into a huge extent. + * + * The last two clauses are what stop a degenerate implementation: without the + * "result is one of the two inputs" clause a function returning 0 bytes forever + * satisfies every bound, and without the progress clause it could stall a + * caller that loops until the window is empty. + */ +/*@ + requires \valid(n); + assigns *n; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> offset < src_len; + ensures \result != 0 ==> *n <= count; + ensures \result != 0 ==> offset + *n <= src_len; + ensures \result != 0 ==> (*n == count || *n == src_len - offset); + ensures (\result != 0 && count > 0) ==> *n > 0; + ensures \result == 0 ==> *n == 0; + */ +static inline int slice_clamp(uint64_t src_len, + uint64_t offset, + uint64_t count, + uint64_t *n) +{ + if (offset >= src_len) { + *n = 0; + return 0; + } + + uint64_t avail = src_len - offset; + + *n = count < avail ? count : avail; + return 1; +} diff --git a/src/syscall/sockaddr-math.h b/src/proved/sockaddr.h similarity index 100% rename from src/syscall/sockaddr-math.h rename to src/proved/sockaddr.h diff --git a/src/core/stack-math.h b/src/proved/stack.h similarity index 99% rename from src/core/stack-math.h rename to src/proved/stack.h index 6053d6d9..341f0696 100644 --- a/src/core/stack-math.h +++ b/src/proved/stack.h @@ -78,7 +78,7 @@ static inline int stack_take(uint64_t *ptr, uint64_t floor, uint64_t bytes) * Written as subtract-the-remainder rather than "& ~15": the compiler emits the * same instruction, and the prover reasons about the arithmetic form without * first establishing that the mask is one less than a power of two. Same reason - * src/core/gva-math.h uses "% granule". + * src/proved/gva.h uses "% granule". */ /*@ assigns \nothing; diff --git a/src/proved/timespec.h b/src/proved/timespec.h new file mode 100644 index 00000000..462ba4ca --- /dev/null +++ b/src/proved/timespec.h @@ -0,0 +1,144 @@ +/* + * Guest timespec arithmetic: the parts a proof can reach + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * nanosleep, clock_nanosleep, ppoll, pselect6, futex, timerfd and epoll_pwait2 + * all take a timespec the guest wrote, and every one of them turns it into + * either a nanosecond count or a millisecond poll timeout. Both conversions + * multiply a guest-chosen tv_sec, so both can overflow, and signed overflow is + * undefined behavior rather than a large number. + * + * time.c's converter guarded the product only for a normalized tv_nsec: with + * tv_sec=1 and tv_nsec=INT64_MAX it took the "no overflow" branch and computed + * 1000000000 + INT64_MAX. Every caller happened to validate first or pass a + * kernel-normalized host value, so the overflow was unreachable by provenance + * rather than by construction. timespec_to_ns_sat is total: it saturates for + * any pair of int64_t values, so the callers' validation is a policy choice + * rather than a safety obligation. + * + * poll.c carried the second conversion twice, and the two copies disagreed: one + * truncated the sub-millisecond remainder and one rounded it up. Truncating + * turns ppoll with a 500 us timeout into poll(0), which returns immediately, so + * a guest sleeping in sub-millisecond ppoll spun at full CPU instead of + * waiting. Linux rounds up. timespec_to_poll_ms rounds up, once. + * + * Split into a header because time.c and poll.c cannot be given to Frama-C: + * they include the macOS time and poll headers, which the analyzer's libc does + * not model. This header needs nothing but stdint.h, so make verify-timespec + * proves it directly. + */ + +#pragma once + +#include + +#define TIMESPEC_NSEC_PER_SEC 1000000000LL +#define TIMESPEC_NSEC_PER_MSEC 1000000LL + +/* Prefixed rather than plain NSEC_PER_SEC: src/utils.h already defines that + * name with a different literal suffix, and this header has to stand alone for + * the prover. time.c static asserts the two agree. + */ + +/* Largest tv_sec whose nanosecond product still fits int64_t. */ +#define TIMESPEC_SEC_MAX (INT64_MAX / TIMESPEC_NSEC_PER_SEC) + +_Static_assert(TIMESPEC_NSEC_PER_SEC == 1000LL * TIMESPEC_NSEC_PER_MSEC, + "the two scales must agree or the ms conversion drifts"); +_Static_assert(TIMESPEC_SEC_MAX > 0, + "the saturation bound must leave a usable range"); + +/* The saturating nanosecond value, as a logic term: the whole conversion in one + * place, defined for every pair of int64_t values. Both contracts below are + * written against it, which is what makes them total. Stating the same thing as + * a set of case hypotheses instead leaves whatever the cases do not cover + * unconstrained, and the uncovered case is exactly where a conforming + * implementation is free to return a wait of zero. + * + * A definition, not an axiom: it unfolds, so nothing here is assumed. + */ +/*@ + logic integer timespec_ns_sat(integer sec, integer nsec) = + (sec < 0 || nsec < 0) ? 0 : + (sec > TIMESPEC_SEC_MAX || + nsec > INT64_MAX - sec * TIMESPEC_NSEC_PER_SEC) ? INT64_MAX : + sec * TIMESPEC_NSEC_PER_SEC + nsec; + */ + +/* Whether a guest timespec is one Linux would accept. + * + * Linux rejects a negative tv_sec and any tv_nsec outside [0, 1e9) with EINVAL + * on the sleep and wait paths. Kept separate from the conversions below because + * it is a policy answer, not a safety one: the conversions are total, so a + * caller that wants Linux's EINVAL asks for it explicitly. + */ +/*@ + assigns \nothing; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> (sec >= 0 && 0 <= nsec < TIMESPEC_NSEC_PER_SEC); + */ +static inline int timespec_valid(int64_t sec, int64_t nsec) +{ + return sec >= 0 && nsec >= 0 && nsec < TIMESPEC_NSEC_PER_SEC; +} + +/* Nanoseconds in a timespec, saturating at INT64_MAX and flooring at 0. + * + * Total by construction: the multiplication happens only under the proved + * tv_sec bound, and the addition only under a proved headroom check. No + * precondition, so no caller can be the one that gets it wrong. + */ +/*@ + assigns \nothing; + ensures in_range: 0 <= \result <= INT64_MAX; + ensures exact: \result == timespec_ns_sat(sec, nsec); + */ +static inline int64_t timespec_to_ns_sat(int64_t sec, int64_t nsec) +{ + if (sec < 0 || nsec < 0) + return 0; + if (sec > TIMESPEC_SEC_MAX) + return INT64_MAX; + + int64_t whole = sec * TIMESPEC_NSEC_PER_SEC; + if (nsec > INT64_MAX - whole) + return INT64_MAX; + return whole + nsec; +} + +/* Milliseconds for poll(2), rounded up and clamped to what its int argument + * holds. + * + * Rounding up is the whole point: a timeout the caller asked to wait for must + * not become a poll that returns immediately, or the caller spins. The residue + * test is written on the already-divided value so nothing has to add 999999 to + * a value that may be INT64_MAX. + */ +/*@ + assigns \nothing; + ensures in_range: 0 <= \result <= INT32_MAX; + ensures never_returns_early: + timespec_ns_sat(sec, nsec) <= + INT32_MAX * TIMESPEC_NSEC_PER_MSEC ==> + \result * TIMESPEC_NSEC_PER_MSEC >= timespec_ns_sat(sec, nsec); + ensures waits_less_than_a_millisecond_too_long: + \result > 0 ==> + (\result - 1) * TIMESPEC_NSEC_PER_MSEC < + timespec_ns_sat(sec, nsec); + ensures clamps_to_int: + timespec_ns_sat(sec, nsec) > + INT32_MAX * TIMESPEC_NSEC_PER_MSEC ==> \result == INT32_MAX; + */ +static inline int timespec_to_poll_ms(int64_t sec, int64_t nsec) +{ + int64_t ns = timespec_to_ns_sat(sec, nsec); + int64_t ms = ns / TIMESPEC_NSEC_PER_MSEC; + + if (ns % TIMESPEC_NSEC_PER_MSEC != 0) + ms++; + if (ms > INT32_MAX) + return INT32_MAX; + return (int) ms; +} diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index f314a630..6023786d 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -59,6 +59,8 @@ #include "string-builder.h" #include "utils.h" +#include "proved/slice.h" + #include "debug/log.h" #include "runtime/procemu.h" #include "runtime/procemu-internal.h" @@ -379,21 +381,20 @@ static int proc_oom_copy_slice(char *dst, size_t count, int64_t offset, const char *src, - size_t src_len, + uint64_t src_len, ssize_t *read_out) { if (offset < 0) { errno = EINVAL; return -1; } - if ((uint64_t) offset >= src_len) { + uint64_t n; + if (!slice_clamp(src_len, (uint64_t) offset, count, &n)) { *read_out = 0; return 1; } - size_t avail = src_len - (size_t) offset; - size_t n = count < avail ? count : avail; - memcpy(dst, src + offset, n); + memcpy(dst, src + offset, (size_t) n); *read_out = (ssize_t) n; return 1; } @@ -3486,21 +3487,17 @@ int proc_intercept_readv(int guest_fd, errno = EIO; return -1; } - size_t src_len = (size_t) len; - if ((uint64_t) offset >= src_len) { - *read_out = 0; - return 1; - } + uint64_t src_len = (uint64_t) len; - size_t src_off = (size_t) offset; + uint64_t src_off = (uint64_t) offset; ssize_t total = 0; - for (int i = 0; i < iovcnt && src_off < src_len; i++) { - size_t n = iov[i].iov_len; - if (n > src_len - src_off) - n = src_len - src_off; + for (int i = 0; i < iovcnt; i++) { + uint64_t n; + if (!slice_clamp(src_len, src_off, iov[i].iov_len, &n)) + break; if (n == 0) continue; - memcpy(iov[i].iov_base, text + src_off, n); + memcpy(iov[i].iov_base, text + src_off, (size_t) n); src_off += n; total += (ssize_t) n; } diff --git a/src/syscall/fdtable.c b/src/syscall/fdtable.c index e6d61663..79febde5 100644 --- a/src/syscall/fdtable.c +++ b/src/syscall/fdtable.c @@ -22,6 +22,8 @@ #include "utils.h" +#include "proved/fdset.h" + #include "core/shim-globals.h" #include "runtime/procemu.h" #include "syscall/linux-wire.h" @@ -61,14 +63,34 @@ void fd_set_rlimit_nofile(int cur) #define FD_BITMAP_WORDS (FD_TABLE_SIZE / 64) static uint64_t fd_free_bitmap[FD_BITMAP_WORDS]; +/* fd_bitmap_find_free leans on fdset_slot for both halves of its bound: that a + * rejected minfd is one this table has no slot for, and that an accepted one + * yields a word inside fd_free_bitmap. Neither holds if the proved bound and + * this table stop describing the same range. + */ +_Static_assert(FDSET_MAX_FDS == FD_TABLE_SIZE, + "the proved fd bound must be this table's bound"); +_Static_assert(FD_BITMAP_WORDS == FDSET_MAX_WORDS, + "the free-fd bitmap and the proved split must span the same " + "number of words"); + +/* Callers own the range check: fd_bitmap_find_free hands back a bounded fd, + * fd_mark_closed_unlocked's caller checks, and fdtable_init passes literals. + * Checking here instead would guard the bitmap word while leaving the + * fd_table[fd] write in fd_init_entry, one line later, just as exposed. + * + * Same shift and mask as the shim's inline bitmap test (see shim.S), and + * unsigned for the same reason: on a signed fd the compiler has to bias the + * value before dividing, for a negative case the callers rule out. + */ static inline void fd_bitmap_set_free(int fd) { - fd_free_bitmap[fd / 64] |= BIT64(fd % 64); + fd_free_bitmap[(unsigned) fd >> 6] |= BIT64((unsigned) fd & 63); } static inline void fd_bitmap_set_used(int fd) { - fd_free_bitmap[fd / 64] &= ~BIT64(fd % 64); + fd_free_bitmap[(unsigned) fd >> 6] &= ~BIT64((unsigned) fd & 63); } /* A host read/write blocks only on non-regular, non-directory fds (pipe, @@ -164,23 +186,26 @@ static int fd_bitmap_find_free(int minfd) { if (minfd < 0) minfd = 0; - if (minfd >= FD_TABLE_SIZE) - return -1; - int word = minfd / 64, bit = minfd % 64; - /* Check the partial first word (mask out bits below minfd) */ - uint64_t masked = fd_free_bitmap[word] & (~0ULL << bit); - if (masked) { - int fd = word * 64 + bit_ctz64(masked); - return (fd < FD_TABLE_SIZE) ? fd : -1; - } + /* A guest chooses minfd through fcntl(F_DUPFD), which forwards the argument + * having rejected only negatives, so this rejection is a real case and not + * a restatement of something already checked. It is also what puts word + * inside fd_free_bitmap. + */ + uint64_t word, bit; + if (!fdset_slot(minfd, &word, &bit)) + return -1; - /* Check remaining full words */ - for (word++; word < FD_BITMAP_WORDS; word++) { - if (fd_free_bitmap[word]) { - int fd = word * 64 + bit_ctz64(fd_free_bitmap[word]); - return (fd < FD_TABLE_SIZE) ? fd : -1; - } + /* Bits below minfd drop out of the first word; every later word is whole. + * A word index under FD_BITMAP_WORDS and a bit index under 64 put the + * result below FD_TABLE_SIZE, so no ceiling is needed on the way out. + */ + for (uint64_t mask = ~0ULL << bit; word < FD_BITMAP_WORDS; + word++, mask = ~0ULL) { + uint64_t free_bits = fd_free_bitmap[word] & mask; + if (free_bits) + return (int) (word * FDSET_BITS_PER_WORD + + (uint64_t) bit_ctz64(free_bits)); } return -1; } @@ -477,10 +502,13 @@ int fd_alloc_at_relaxed(int fd, return fd; } -/* Internal: mark fd closed with fd_lock already held. Clear host_fd and dir - * BEFORE marking the slot free in the bitmap. Otherwise another thread could - * fd_alloc() this slot, populate it with a new host_fd/dir, and then the - * current stale writes would corrupt the new entry. +/* Internal: mark fd closed with fd_lock already held. Requires + * 0 <= fd < FD_TABLE_SIZE; it indexes fd_table and the free bitmap without + * rechecking, so a caller that has not established that corrupts both. + * + * Clear host_fd and dir BEFORE marking the slot free in the bitmap. Otherwise + * another thread could fd_alloc() this slot, populate it with a new + * host_fd/dir, and then the current stale writes would corrupt the new entry. */ void fd_mark_closed_unlocked(int fd) { diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 0a8c6254..dd7d8d8d 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -24,6 +24,17 @@ #include "debug/log.h" #include "utils.h" +#include "proved/dirent.h" + +/* dirent_record_bounds' precondition is name_len <= DIRENT64_NAME_MAX, and the + * translation buffer below is sized from the host's NAME_MAX. proved/dirent.h + * deliberately does not take that constant from limits.h, since the 255 it + * states is the guest's limit; this ties the two so a host with a larger + * NAME_MAX cannot slip a filename past the proof and overrun entry_buf. + */ +_Static_assert(NAME_MAX == DIRENT64_NAME_MAX, + "the dirent name bound must match the proved one"); + #include "core/shim-globals.h" /* shim_globals_mark_urandom_fd */ #include "runtime/procemu.h" @@ -965,6 +976,7 @@ static int duplicate_guest_fd(int src_fd, * race and leak the slave fd. No-op when the source has no keepalive. */ proc_pty_dup_keepalive_locked(src_snap.host_fd, new_host_fd); + /* Same reasoning for the slave side: the alias is a live reference to the * pty and must be on the books before the guest fd is published, or the * source's close will retire the only counted reference. @@ -1628,12 +1640,17 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) size_t guest_pos = 0; struct dirent *de; - /* Temp buffer for dirent serialization. Max dirent64 is 280 bytes (19-byte - * header + NAME_MAX=255 + null + padding to 8). Using a stack buffer avoids - * guest_ptr boundary issues: guest_write() handles 2MiB block crossings - * that raw memcpy into guest_ptr() cannot. + /* Temp buffer for dirent serialization. dirent_record_bounds proves every + * record it accepts fits DIRENT64_MAX_RECLEN, so nothing below re-checks + * the extent. Using a stack buffer avoids guest_ptr boundary issues: + * guest_write() handles 2MiB block crossings that raw memcpy into + * guest_ptr() cannot. + * + * guest_pos <= count holds on every iteration, which is what lets the call + * below meet its precondition: it starts at 0 and only advances by a reclen + * the same call proved fits in count - guest_pos. */ - uint8_t entry_buf[280]; + uint8_t entry_buf[DIRENT64_MAX_RECLEN]; /* One answer per call, not per entry: which side of the sysroot boundary * the stream reads from is a property of the directory. @@ -1682,11 +1699,14 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) goto out; } - size_t name_len = strlen(guest_name); - /* Linux dirent64: 19-byte header + name + null, padded to 8 */ - size_t reclen = (19 + name_len + 1 + 7) & ~7ULL; + /* path_translate_dirent_name wrote into a NAME_MAX + 1 buffer, so the + * length is within dirent_record_bounds' precondition. + */ + uint64_t name_len = strlen(guest_name); + uint64_t reclen, pad_start; - if (guest_pos + reclen > count) { + if (!dirent_record_bounds(name_len, guest_pos, count, &reclen, + &pad_start)) { /* Entry does not fit; rewind so next call gets it */ seekdir(dir, saved_pos); break; @@ -1702,8 +1722,7 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) * guest_write() which handles 2MiB block boundary crossings. */ memcpy(entry_buf, &lde, sizeof(lde)); - memcpy(entry_buf + 19, guest_name, name_len + 1); - size_t pad_start = 19 + name_len + 1; + memcpy(entry_buf + DIRENT64_HDR_BYTES, guest_name, name_len + 1); if (pad_start < reclen) memset(entry_buf + pad_start, 0, reclen - pad_start); diff --git a/src/syscall/fuse.c b/src/syscall/fuse.c index 00c4838a..dbc5645e 100644 --- a/src/syscall/fuse.c +++ b/src/syscall/fuse.c @@ -24,7 +24,8 @@ #include "syscall/linux-wire.h" #include "syscall/asyncio.h" -#include "syscall/fuse-math.h" +#include "proved/dirent.h" +#include "proved/fuse.h" #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/path.h" @@ -174,8 +175,8 @@ typedef struct { uint16_t padding; } fuse_in_header_t; -/* fuse_out_header_t lives in syscall/fuse-math.h, next to the frame arithmetic - * proved against it. +/* fuse_out_header_t lives in proved/fuse.h, next to the frame arithmetic proved + * against it. */ typedef struct { @@ -219,7 +220,7 @@ typedef struct { #define FUSE_NODE_REF_HASH_TOMBSTONE (-2) #define FUSE_FAKE_DEV 0xF00D -/* FUSE_FRAME_CAP and FUSE_MAX_NEGOTIATED_WRITE live in syscall/fuse-math.h. */ +/* FUSE_FRAME_CAP and FUSE_MAX_NEGOTIATED_WRITE live in proved/fuse.h. */ typedef struct fuse_request { bool used; @@ -2330,7 +2331,7 @@ int64_t fuse_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) * overflow the fixed entry[] buffer below or exceed the remaining frame * body. */ - if (fde->namelen > 255) { + if (fde->namelen > DIRENT64_NAME_MAX) { free(tmp); fuse_file_release(&snap); return dst ? (int64_t) dst : -LINUX_EIO; @@ -2341,16 +2342,16 @@ int64_t fuse_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) if (src + freclen > (size_t) raw) break; - size_t lreclen = (19 + fde->namelen + 1 + 7) & ~7ULL; - - /* d_ino(8) + d_off(8) + d_reclen(2) + d_type(1) + name(<=255) + NUL(1) - * + padding(<=7) <= 280. Defense in depth against an arithmetic error - * -- never trust the daemon's record length. + /* dirent_record_bounds proves the record fits both this buffer and the + * guest's remaining count, so the two hand-written checks that used to + * stand here (lreclen > sizeof(entry), dst + lreclen > count) are now + * postconditions. dst <= count holds by induction: it starts at 0 and + * only advances by a length the same call proved fits count - dst. */ - uint8_t entry[280]; - if (lreclen > sizeof(entry)) - break; - if (dst + lreclen > count) + uint8_t entry[DIRENT64_MAX_RECLEN]; + uint64_t lreclen, pad_start; + if (!dirent_record_bounds(fde->namelen, dst, count, &lreclen, + &pad_start)) break; struct { @@ -2365,11 +2366,10 @@ int64_t fuse_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) .d_type = (uint8_t) fde->type, }; memcpy(entry, &lde, sizeof(lde)); - memcpy(entry + 19, fde->name, fde->namelen); - entry[19 + fde->namelen] = '\0'; - if (19 + fde->namelen + 1 < lreclen) - memset(entry + 19 + fde->namelen + 1, 0, - lreclen - (19 + fde->namelen + 1)); + memcpy(entry + DIRENT64_HDR_BYTES, fde->name, fde->namelen); + entry[DIRENT64_HDR_BYTES + fde->namelen] = '\0'; + if (pad_start < lreclen) + memset(entry + pad_start, 0, lreclen - pad_start); if (guest_write(g, buf_gva + dst, entry, lreclen) < 0) { free(tmp); fuse_file_release(&snap); @@ -2510,7 +2510,7 @@ int64_t fuse_dev_write(guest_t *g, fuse_out_header_t hdr; memcpy(&hdr, buf, sizeof(hdr)); - /* Proved in src/syscall/fuse-math.h: on success the payload at buf + + /* Proved in src/proved/fuse.h: on success the payload at buf + * FUSE_OUT_HDR_BYTES for reply_len bytes lies inside the count bytes read * above. */ diff --git a/src/syscall/internal.h b/src/syscall/internal.h index 9914a312..37c15ad1 100644 --- a/src/syscall/internal.h +++ b/src/syscall/internal.h @@ -32,6 +32,9 @@ #include #include +#include "proved/iov.h" +#include "proved/timespec.h" + #include "syscall/linux-wire.h" #include "syscall/linux-limits.h" #include "runtime/thread.h" @@ -424,7 +427,9 @@ static inline int64_t host_fd_ref_open_io(guest_fd_t guest_fd, * written together with a fresh generation, so the generation alone pins the * identity of the file behind ref->fd. * - * *out_gen is 0 on failure. Returns 0 on success, -LINUX_EBADF otherwise. + * *out_gen is 0 on failure. + * + * Returns 0 on success, -LINUX_EBADF otherwise. */ static inline int64_t host_fd_ref_open_io_gen(guest_fd_t guest_fd, host_fd_ref_t *ref, @@ -462,13 +467,53 @@ static inline int64_t host_fd_ref_open_io_gen(guest_fd_t guest_fd, return 0; } +/* A guest timeout above this many seconds means "wait indefinitely", and the + * wait paths spell indefinite as timeout_ms = -1. The comparison is strict, so + * this exact value is still converted; the cutoff carries no meaning of its own + * beyond being far past any real timeout, and one second either side of it is + * equally unreachable. Both callers spelled it strictly before this helper + * existed, and moving the boundary would change what they return. + * + * That -1 is load-bearing, not a rounding convenience. sys_epoll_pwait reads + * timeout_ms < 0 as has_timeout = false, which selects the 200 ms re-arm loop + * that re-checks exit_group, futex interrupts, pending signals and pty hangup + * between kevent calls. The epoll path registers no wakeup-pipe fd, so that + * loop is its ONLY interruption mechanism: converting a huge timeout into a + * finite one instead parks the thread in a single uninterruptible kevent, and a + * sibling exit_group can no longer wake it. + * + * 2000000 seconds is about 23 days, comfortably past any real timeout and short + * of the arithmetic limits. + */ +#define SYSCALL_TIMEOUT_FOREVER_SEC 2000000LL + +/* Guest timespec to a poll(2)/kevent millisecond timeout, mapping an + * effectively-infinite request onto the -1 that selects the interruptible path. + * Two callers need exactly this (epoll_pwait2 and recvmmsg); ppoll and pselect6 + * do not, because neither ever spelled a timespec as indefinite. + */ +static inline int syscall_timeout_ms_or_forever(int64_t sec, int64_t nsec) +{ + if (sec > SYSCALL_TIMEOUT_FOREVER_SEC) + return -1; + return timespec_to_poll_ms(sec, nsec); +} + /* iov limits shared between readv/writev/preadv/pwritev and sendmsg/recvmsg. * SYSCALL_IOV_MAX matches the Linux UIO_MAXIOV cap; SYSCALL_IOV_STACK_MAX keeps * the typical case on the call-site stack. + * + * The cap is stated twice because the proved copy in proved/iov.h cannot + * include this header (Frama-C's libc does not model the macOS uio headers it + * pulls in). The assertion below is what keeps the two from drifting: a proof + * about a 1024 cap says nothing about a 2048 one. */ #define SYSCALL_IOV_MAX 1024 #define SYSCALL_IOV_STACK_MAX 64 +_Static_assert(SYSCALL_IOV_MAX == IOV_COUNT_MAX, + "the iovcnt cap the code enforces must be the one proved"); + /* Resolved host iov vector backed by an inline stack buffer with a heap * fallback for large iovcnt. Pair host_iov_prepare with host_iov_free. */ diff --git a/src/syscall/io.c b/src/syscall/io.c index b8e3963c..62699d52 100644 --- a/src/syscall/io.c +++ b/src/syscall/io.c @@ -30,6 +30,8 @@ #include "utils.h" +#include "proved/slice.h" + #include "core/rosetta.h" #include "core/shim-globals.h" #include "hvutil.h" @@ -310,11 +312,10 @@ static int64_t urandom_fill_iov(int guest_fd, if (err < 0) return err; - size_t total = 0; + uint64_t total = 0; for (int i = 0; i < iovcnt; i++) { - if (iov[i].iov_len > (size_t) SSIZE_MAX - total) + if (!iov_total_add(total, iov[i].iov_len, &total)) return -LINUX_EINVAL; - total += iov[i].iov_len; } if (total == 0) return 0; @@ -349,18 +350,17 @@ static int64_t urandom_fill_iov(int guest_fd, static int64_t validate_iov_total(guest_t *g, uint64_t iov_gva, int iovcnt) { - if (iovcnt <= 0 || iovcnt > SYSCALL_IOV_MAX) + if (!iov_count_ok(iovcnt)) return -LINUX_EINVAL; - size_t total = 0; + uint64_t total = 0; for (int i = 0; i < iovcnt; i++) { linux_iovec_t giov; if (guest_read_small(g, iov_gva + (uint64_t) i * sizeof(giov), &giov, sizeof(giov)) < 0) return -LINUX_EFAULT; - if (giov.iov_len > (uint64_t) SSIZE_MAX - total) + if (!iov_total_add(total, giov.iov_len, &total)) return -LINUX_EINVAL; - total += (size_t) giov.iov_len; } return 0; } @@ -847,10 +847,10 @@ static int64_t host_fd_ref_open_checked(int guest_fd, /* True when a read on this pty master must fail with EIO. * - * Linux fails every read variant once the master has hung up, not just - * read(2). Only after the queue drains: a shell that printed on its way out - * leaves that output behind, and Linux hands it over before reporting the - * hangup, so deciding on the hangup first would swallow it. + * Linux fails every read variant once the master has hung up, not just read(2). + * Only after the queue drains: a shell that printed on its way out leaves that + * output behind, and Linux hands it over before reporting the hangup, so + * deciding on the hangup first would swallow it. * * Without this the host read simply blocks -- elfuse's keepalive slave keeps * the pty alive from its point of view -- so a terminal that drains its master @@ -947,15 +947,27 @@ static int64_t proc_try_writev_intercept(int fd, int64_t offset, int use_pwrite) { - size_t total = 0; + uint64_t total = 0; char stack_buf[256]; char *buf = stack_buf; char *heap = NULL; ssize_t written = 0; int handled; - for (int i = 0; i < iovcnt; i++) - total += iov[i].iov_len; + /* The sum feeds malloc() and then a memcpy loop that writes exactly that + * many bytes, so a wrapped total here is a short allocation followed by a + * long copy. host_iov_prepare clamps every entry to the guest mapping it + * points into, which made the wrap unreachable by provenance rather than by + * construction; iov_total_add makes it unreachable by construction. + */ + for (int i = 0; i < iovcnt; i++) { + /* INT64_MIN is this helper's "not handled", and that is the right + * answer: the fd may well not be a /proc node, so the size verdict + * belongs to the real writev below, not to the interceptor. + */ + if (!iov_total_add(total, iov[i].iov_len, &total)) + return INT64_MIN; + } if (total > sizeof(stack_buf)) { heap = malloc(total); if (!heap) @@ -1298,7 +1310,7 @@ int64_t host_iov_prepare(guest_t *g, buf->iov = buf->stack; buf->heap = NULL; - if (iovcnt <= 0 || iovcnt > SYSCALL_IOV_MAX) + if (!iov_count_ok(iovcnt)) return -LINUX_EINVAL; if (iovcnt > SYSCALL_IOV_STACK_MAX) { @@ -1825,12 +1837,10 @@ static int64_t process_vm_import_iov(guest_t *g, uint64_t total = 0; for (uint64_t i = 0; i < iovcnt; i++) { - if (iov[i].iov_len > (uint64_t) SSIZE_MAX || - total > (uint64_t) SSIZE_MAX - iov[i].iov_len) { + if (!iov_total_add(total, iov[i].iov_len, &total)) { free(iov); return -LINUX_EINVAL; } - total += iov[i].iov_len; } *iov_out = iov; @@ -2363,6 +2373,7 @@ int64_t sys_ioctl(guest_t *g, int fd, uint64_t request, uint64_t arg) host_fd_ref_close(&host_ref); return -LINUX_EINVAL; } + /* Resolve the Linux pts number before opening, the same way TIOCGPTN * does: the slave handed out here counts toward the master's hangup * accounting, and that table is keyed by pts number. Pass the guest fd @@ -2499,13 +2510,17 @@ int64_t sys_fallocate(int fd, int mode, int64_t offset, int64_t len) host_fd_ref_close(&host_ref); return linux_errno(); } - if (offset >= st.st_size) { + + /* Zero only through the current EOF, so KEEP_SIZE stays guest-visible. + * st_size is signed and offset is already proved non-negative above. + */ + uint64_t window; + if (!slice_clamp((uint64_t) st.st_size, (uint64_t) offset, + (uint64_t) len, &window)) { host_fd_ref_close(&host_ref); return 0; } - int64_t remaining = st.st_size - offset; - if (remaining > len) - remaining = len; + int64_t remaining = (int64_t) window; static const char zeros[4096]; off_t cur = (off_t) offset; diff --git a/src/syscall/mem.c b/src/syscall/mem.c index f0ceccde..e237409d 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -23,6 +23,8 @@ #include "debug/log.h" #include "utils.h" +#include "proved/align.h" + #include "runtime/thread.h" #include "syscall/linux-wire.h" #include "syscall/fuse.h" @@ -598,7 +600,9 @@ static uint64_t find_free_gap_inner(const guest_t *g, * segment, which reduces segment-table fragmentation for memfd-style * allocation patterns. */ - uint64_t gap_start = ALIGN_UP(min_addr, align); + uint64_t gap_start; + if (!align_up_ok(min_addr, align, &gap_start)) + return UINT64_MAX; /* Skip the prefix of regions entirely below gap_start in O(log n). After a * successful allocation the gap hint advances near or past the existing @@ -607,7 +611,7 @@ static uint64_t find_free_gap_inner(const guest_t *g, */ for (int i = guest_region_first_end_above(g, gap_start); i < g->nregions; i++) { - /* A region can still slip below gap_start after the ALIGN_UP advance + /* A region can still slip below gap_start after the align_up_ok advance * below skips past a smaller adjacent region; keep the cheap guard. */ if (g->regions[i].end <= gap_start) @@ -625,18 +629,22 @@ static uint64_t find_free_gap_inner(const guest_t *g, * contain entries beyond max_addr that could push gap_start past the * valid range. */ - if (gap_start <= max_addr && length <= max_addr - gap_start && + if (window_fits(gap_start, length, max_addr) && g->regions[i].start >= gap_start + length) return gap_start; /* Region overlaps; advance past it and round to the next aligned * boundary so the caller's alignment promise holds across allocations. + * A round-up that would leave the address space ends the search: with + * ALIGN_UP's wrap the walk would resume below the regions it already + * passed and hand back a gap that overlaps one of them. */ - gap_start = ALIGN_UP(g->regions[i].end, align); + if (!align_up_ok(g->regions[i].end, align, &gap_start)) + return UINT64_MAX; } /* Check trailing space after all regions */ - if (gap_start <= max_addr && length <= max_addr - gap_start) + if (window_fits(gap_start, length, max_addr)) return gap_start; return UINT64_MAX; /* No suitable gap found */ } diff --git a/src/syscall/net-abi.c b/src/syscall/net-abi.c index b6d71344..2fbaa1f4 100644 --- a/src/syscall/net-abi.c +++ b/src/syscall/net-abi.c @@ -13,7 +13,7 @@ #include "syscall/net.h" #include "syscall/net-abi.h" -#include "syscall/sockaddr-math.h" +#include "proved/sockaddr.h" int socket_small_int_normalize(int level, int optname, int value) { @@ -220,8 +220,8 @@ int linux_to_mac_sockaddr(const void *linux_sa, mac_sa->ss_len = (uint8_t) linux_len; mac_sa->ss_family = (uint8_t) mac_family; - /* Proved in src/syscall/sockaddr-math.h: the copy below stays inside both - * the source length and this destination. + /* Proved in src/proved/sockaddr.h: the copy below stays inside both the + * source length and this destination. */ uint32_t data_len = (uint32_t) sockaddr_payload_len(linux_len, sizeof(*mac_sa)); diff --git a/src/syscall/net-msg.c b/src/syscall/net-msg.c index 2205f390..45868e96 100644 --- a/src/syscall/net-msg.c +++ b/src/syscall/net-msg.c @@ -19,7 +19,8 @@ #include "utils.h" -#include "syscall/cmsg-math.h" +#include "proved/cmsg.h" +#include "proved/timespec.h" #include "syscall/internal.h" #include "syscall/io.h" #include "syscall/net.h" @@ -307,9 +308,9 @@ int64_t sys_sendmsg(guest_t *g, int fd, uint64_t msg_gva, int linux_flags) memcpy(&lcmsg_level, linux_ctrl + lpos + 8, 4); memcpy(&lcmsg_type, linux_ctrl + lpos + 12, 4); - /* Proved in src/syscall/cmsg-math.h: on success the ldata_len - * payload bytes at lpos + CMSG_LINUX_HDR_BYTES lie inside - * linux_ctrl, and next_lpos is strictly past lpos. + /* Proved in src/proved/cmsg.h: on success the ldata_len payload + * bytes at lpos + CMSG_LINUX_HDR_BYTES lie inside linux_ctrl, and + * next_lpos is strictly past lpos. */ uint64_t ldata_len, next_lpos; if (!cmsg_entry_bounds(lpos, lctl_len, lcmsg_len, &ldata_len, @@ -1067,11 +1068,14 @@ int64_t sys_recvmmsg(guest_t *g, host_fd_ref_close(&host_ref); return -LINUX_EINVAL; } - int timeout_ms; - if (ts.tv_sec > 2000000) - timeout_ms = -1; - else - timeout_ms = (int) (ts.tv_sec * 1000 + ts.tv_nsec / 1000000); + + /* Same conversion as ppoll, pselect6 and epoll_pwait2. Truncating + * turned a sub-millisecond timeout into poll(0) and an immediate + * EAGAIN, and the old tv_sec ceiling turned a large finite timeout + * into an infinite wait. The EINVAL half is the guard above. + */ + int timeout_ms = + syscall_timeout_ms_or_forever(ts.tv_sec, ts.tv_nsec); struct pollfd pfd = {.fd = host_ref.fd, .events = POLLIN}; int pr = poll(&pfd, 1, timeout_ms); host_fd_ref_close(&host_ref); diff --git a/src/syscall/netlink.c b/src/syscall/netlink.c index ec3d8bfc..3de2e7f0 100644 --- a/src/syscall/netlink.c +++ b/src/syscall/netlink.c @@ -35,7 +35,7 @@ #include "syscall/internal.h" #include "syscall/io.h" /* io_wait_fd_or_interrupted */ #include "syscall/net.h" -#include "syscall/netlink-math.h" +#include "proved/netlink.h" #include "utils.h" #include @@ -48,7 +48,7 @@ static void netlink_close(int guest_fd); /* Linux netlink message structures. These structures are defined manually to * match the Linux ABI exactly, since macOS has no . The two * headers the walks step over, nlmsghdr_t and rtattr_t, live in - * syscall/netlink-math.h with NLMSG_HDRLEN, RTA_HDRLEN and the arithmetic + * proved/netlink.h with NLMSG_HDRLEN, RTA_HDRLEN and the arithmetic * verify-netlink proves against them. The reply builders below round with the * same netlink_align_up as the walks, so the two cannot round differently. */ @@ -509,7 +509,7 @@ static void nl_parse_link_filter(const uint8_t *req, rtattr_t rta; memcpy(&rta, req + off, sizeof(rta)); - /* Proved in src/syscall/netlink-math.h: on success the payload at off + + /* Proved in src/proved/netlink.h: on success the payload at off + * RTA_HDRLEN for data_len bytes lies inside total, and next_off is * strictly past off. */ @@ -731,10 +731,9 @@ static size_t nl_complete_span(const netlink_state_t *ns, size_t to_copy) nlmsghdr_t hdr; memcpy(&hdr, ns->buf + pos, sizeof(hdr)); - /* Proved in src/syscall/netlink-math.h: on success span is strictly - * positive, so this loop advances for any header at all. Before the - * widening this loop could spin forever on a guest-chosen length; see - * the header. + /* Proved in src/proved/netlink.h: on success span is strictly positive, + * so this loop advances for any header at all. Before the widening this + * loop could spin forever on a guest-chosen length; see the header. */ uint64_t span; if (!netlink_msg_span(hdr.nlmsg_len, &span)) diff --git a/src/syscall/path.c b/src/syscall/path.c index 3348ff4e..a3673bfe 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -21,6 +21,8 @@ #include "syscall/linux-wire.h" #include "syscall/casefold-walk.h" #include "syscall/fuse.h" +#include "proved/pathdepth.h" + #include "syscall/path.h" #include "syscall/proc.h" @@ -500,8 +502,9 @@ static size_t path_lexical_depth(const char *path) if (path_component_is_dot(comp, len)) continue; if (path_component_is_dotdot(comp, len)) { - if (depth > 0) - depth--; + uint64_t popped; + if (path_depth_pop(depth, &popped)) + depth = popped; continue; } depth++; @@ -677,9 +680,10 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) continue; if (clamp) { if (path_component_is_dotdot(comp, len)) { - if (depth == 0) + uint64_t popped; + if (!path_depth_pop(depth, &popped)) continue; /* '..' at the guest root names the root */ - depth--; + depth = popped; } else { depth++; } @@ -751,7 +755,6 @@ static int proc_push_component(char *out, marks[*depth] = cur; memcpy(out + cur, comp, len); out[cur + len] = '\0'; - (*depth)++; return 0; } @@ -762,7 +765,6 @@ static int proc_push_component(char *out, out[write_pos] = '/'; memcpy(out + write_pos + 1, comp, len); out[write_pos + 1 + len] = '\0'; - (*depth)++; return 0; } @@ -790,19 +792,28 @@ static int proc_apply_components(const char *path, continue; } if (len == 2 && seg[0] == '.' && seg[1] == '.') { - if (*depth > 0) { - *depth -= 1; + uint64_t popped; + if (path_depth_pop(*depth, &popped)) { + *depth = popped; out[marks[*depth]] = '\0'; } seg = end; continue; } - if (*depth >= marks_cap) { + + /* The bound and the advance are one step: path_depth_push refuses at + * capacity, so the marks[] write inside proc_push_component is in range + * by postcondition rather than by a check the caller repeats. The depth + * advances only after the write succeeds, as before. + */ + uint64_t pushed; + if (!path_depth_push(*depth, marks_cap, &pushed)) { errno = ENAMETOOLONG; return -1; } if (proc_push_component(out, outsz, marks, depth, seg, len) < 0) return -1; + *depth = pushed; seg = end; } return 0; diff --git a/src/syscall/poll.c b/src/syscall/poll.c index db0a50b7..031f6e7e 100644 --- a/src/syscall/poll.c +++ b/src/syscall/poll.c @@ -22,6 +22,9 @@ #include "utils.h" +#include "proved/fdset.h" +#include "proved/timespec.h" + #include "debug/log.h" #include "runtime/futex.h" @@ -35,6 +38,16 @@ #include "syscall/time.h" /* linux_timespec_valid */ #include "syscall/wakeup-pipe.h" +/* The proof in proved/fdset.h bounds nfds by FDSET_MAX_FDS and sizes the + * bitmask buffers below from FDSET_MAX_WORDS. That is only the right bound if + * it is also the fd table's: pselect6 used to reject on the host's FD_SETSIZE + * instead, two constants that are both 1024 on macOS but are not the same + * constant, so a host with a larger FD_SETSIZE would have read guest bytes past + * three stack arrays. + */ +_Static_assert(FDSET_MAX_FDS == FD_TABLE_SIZE, + "the accepted nfds bound must be the fd table's size"); + /* polling/select. */ typedef struct { @@ -73,6 +86,7 @@ int64_t sys_ppoll(guest_t *g, struct pollfd host_fds[256]; host_fd_ref_t host_refs[256]; bool need_pollnval[256] = {false}; + /* Generation pinned per entry in the same fd_lock window as its host fd. * The pty hangup checks below re-resolve the guest fd, so each needs a * witness that the slot still holds the very file this poll resolved; 0 @@ -145,13 +159,12 @@ int64_t sys_ppoll(guest_t *g, host_fd_refs_close(host_refs, nfds); return -LINUX_EINVAL; } - /* Guard against overflow: tv_sec * 1000 can exceed INT64_MAX */ - int64_t ms64; - if (lts.tv_sec > INT64_MAX / 1000) - ms64 = INT64_MAX; - else - ms64 = lts.tv_sec * (int64_t) 1000 + lts.tv_nsec / 1000000; - timeout_ms = (ms64 > INT_MAX) ? INT_MAX : (int) ms64; + + /* Rounds the sub-millisecond remainder up: truncating turned a 500 us + * ppoll into poll(0), which returns immediately, so a guest waiting in + * sub-millisecond ppoll spun instead of sleeping. + */ + timeout_ms = timespec_to_poll_ms(lts.tv_sec, lts.tv_nsec); } /* Atomically install signal mask for the duration of the poll */ @@ -305,7 +318,8 @@ int64_t sys_pselect6(guest_t *g, * it. The sixth argument is a pointer to a struct: * { const sigset_t *ss; size_t ss_len; } */ - if (nfds < 0 || nfds > FD_SETSIZE) + uint64_t nfds_words_u; + if (!fdset_words(nfds, &nfds_words_u)) return -LINUX_EINVAL; if (nfds == 0 && readfds_gva == 0 && writefds_gva == 0 && @@ -336,18 +350,19 @@ int64_t sys_pselect6(guest_t *g, if (exceptfds_gva) except_setp = &except_set; - int max_host_fd = -1, nfds_words = (nfds + 63) / 64; + int max_host_fd = -1, nfds_words = (int) nfds_words_u; pselect_req_t reqs_stack[64]; pselect_req_t *reqs = reqs_stack; pselect_req_t *reqs_heap = NULL; int req_count = 0; /* Translate fd_sets from guest. Linux fd_set uses unsigned long bitmask. - * FD_TABLE_SIZE=1024 -> max 16 uint64_t words (128 bytes). + * fdset_words proved nfds_words <= FDSET_MAX_WORDS, so bitmask_bytes below + * cannot exceed what these three buffers hold. */ if (readfds_gva || writefds_gva || exceptfds_gva) { - uint64_t rbits_buf[FD_TABLE_SIZE / 64], wbits_buf[FD_TABLE_SIZE / 64]; - uint64_t ebits_buf[FD_TABLE_SIZE / 64]; + uint64_t rbits_buf[FDSET_MAX_WORDS], wbits_buf[FDSET_MAX_WORDS]; + uint64_t ebits_buf[FDSET_MAX_WORDS]; uint64_t *rbits = NULL; uint64_t *wbits = NULL; uint64_t *ebits = NULL; @@ -394,8 +409,20 @@ int64_t sys_pselect6(guest_t *g, (ebits ? ebits[word] : 0); while (requested) { int bit_index = bit_ctz64(requested); - int i = word * 64 + bit_index; + uint64_t fd_index; uint64_t bit = BIT64(bit_index); + + /* Bits above nfds in the last word are the guest's to set and + * Linux ignores them (fs/select.c bounds its per-word loop by + * n). Honoring them polled an fd the caller never asked about, + * and returned EBADF when it was not open. + */ + if (!fdset_fd_index(nfds, (uint64_t) word, (uint64_t) bit_index, + &fd_index)) { + requested &= requested - 1; + continue; + } + int i = (int) fd_index; host_fd_ref_t ref = {.fd = -1, .owned = false}; if (host_fd_ref_open_io(i, &ref) < 0) goto pselect_badf; @@ -552,13 +579,8 @@ int64_t sys_pselect6(guest_t *g, } const struct timespec *wait_ts = has_timeout ? &ts : &poll_ts; - int64_t ms64; - if (wait_ts->tv_sec > INT64_MAX / 1000) - ms64 = INT64_MAX; - else - ms64 = wait_ts->tv_sec * (int64_t) 1000 + - (wait_ts->tv_nsec + 999999) / 1000000; - int timeout_ms = (ms64 > INT_MAX) ? INT_MAX : (int) ms64; + int timeout_ms = + timespec_to_poll_ms(wait_ts->tv_sec, wait_ts->tv_nsec); ret = poll(poll_fds, (nfds_t) poll_count, timeout_ms); if (ret >= 0) { @@ -614,8 +636,8 @@ int64_t sys_pselect6(guest_t *g, /* Write back result fd_sets (zero then set bits for matching fds) */ if (readfds_gva || writefds_gva || exceptfds_gva) { - uint64_t rbits_buf[FD_TABLE_SIZE / 64], wbits_buf[FD_TABLE_SIZE / 64]; - uint64_t ebits_buf[FD_TABLE_SIZE / 64]; + uint64_t rbits_buf[FDSET_MAX_WORDS], wbits_buf[FDSET_MAX_WORDS]; + uint64_t ebits_buf[FDSET_MAX_WORDS]; uint64_t *rbits = NULL; uint64_t *wbits = NULL; uint64_t *ebits = NULL; @@ -1251,9 +1273,9 @@ int64_t sys_epoll_ctl(guest_t *g, int epfd, int op, int fd, uint64_t event_gva) * which holds fd_lock and then takes inst->lock. So candidates are snapshotted * under the reg lock in bounded batches and tested once it is dropped. * - * Returns the number of guest fds written to out_gfds, capped at max. - * out_gens receives the registration generation each hit was tested against, so - * the caller can re-verify it under inst->lock before acting: a sibling can + * Returns the number of guest fds written to out_gfds, capped at max. out_gens + * receives the registration generation each hit was tested against, so the + * caller can re-verify it under inst->lock before acting: a sibling can * EPOLL_CTL_DEL and re-ADD the same fd number while the lock is dropped, and * stamping the hangup then would attach it to the new registration's data. */ @@ -1289,6 +1311,7 @@ static int epoll_collect_hung_up(epoll_instance_t *inst, if (inst->regs[gfd].oneshot_armed || !inst->regs[gfd].pty_master) continue; cand_gfds[ncand] = gfd; + /* Carry the generation the registration pinned at ADD/MOD, so a * close+reopen into the same fd number cannot be mistaken for the * registered master. @@ -1518,6 +1541,7 @@ int64_t sys_epoll_pwait(guest_t *g, */ for (int i = 0; i < nhup; i++) { int gfd = hup_gfds[i]; + /* Re-check under the lock: the collector tested unlocked, so a * concurrent epoll_ctl or close hook may have retired the entry since. * The generation match is what rejects a DEL + re-ADD of the same fd diff --git a/src/syscall/signal.c b/src/syscall/signal.c index 5af1a223..1d7898e4 100644 --- a/src/syscall/signal.c +++ b/src/syscall/signal.c @@ -37,7 +37,7 @@ #include "syscall/linux-wire.h" #include "syscall/fd.h" /* signalfd_notify */ #include "syscall/proc.h" /* proc_get_pid, proc_get_uid, SYSCALL_EXEC_HAPPENED */ -#include "syscall/sigframe-math.h" +#include "proved/sigframe.h" #include "syscall/signal.h" #include "syscall/time.h" /* linux_timespec_valid, linux_timespec_to_ns_sat */ #include "syscall/wakeup-pipe.h" @@ -1994,11 +1994,11 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, use_altstack = true; } - /* Proved in src/syscall/sigframe-math.h: on success the frame is 16-byte - * aligned, sits wholly below signal_sp without the subtraction wrapping, - * and stays at or above the floor. The floor is the altstack base when - * running on one, the bound signal_sp alone cannot express; on the normal - * stack it is 0 and only the fits-below-SP bound applies. + /* Proved in src/proved/sigframe.h: on success the frame is 16-byte aligned, + * sits wholly below signal_sp without the subtraction wrapping, and stays + * at or above the floor. The floor is the altstack base when running on + * one, the bound signal_sp alone cannot express; on the normal stack it is + * 0 and only the fits-below-SP bound applies. */ uint64_t frame_sp; if (!sigframe_base(signal_sp, sizeof(frame), diff --git a/src/syscall/signal.h b/src/syscall/signal.h index b0a1c533..ed2fbfb2 100644 --- a/src/syscall/signal.h +++ b/src/syscall/signal.h @@ -12,6 +12,8 @@ #pragma once +#include + #include #include #include @@ -100,6 +102,7 @@ typedef enum { /* Linux siginfo_t (aarch64, 128 bytes). */ typedef struct { int32_t si_signo, si_errno, si_code, _pad0; + /* Common Linux siginfo fields on aarch64. The union payload starts at * offset 16; queued RT signals carry sigval at offset 24. */ @@ -162,6 +165,39 @@ typedef struct { linux_ucontext_t uc; } linux_rt_sigframe_t; +/* Field offsets against arch/arm64 Linux, which is what makes rt_sigreturn + * work: musl and glibc both return through __restore_rt, which reads this frame + * back at these exact offsets. A silent drift here restores garbage into the + * guest's registers, and no test would name the struct that caused it. + * + * The values are derived, not observed. sigcontext is fault_address at 0, + * regs[31] at 8 through 256, sp 256, pc 264, pstate 272; __reserved carries + * __attribute__((aligned(16))), so it starts at 288 rather than 280. ucontext + * is uc_flags 0, uc_link 8, uc_stack 16 (stack_t is 24 bytes), uc_sigmask 40, + * then 120 bytes of __unused reaching 168, and uc_mcontext is 16-byte aligned + * so it starts at 176. rt_sigframe puts uc after a 128-byte siginfo. + * + * sigframe.h proves where the frame lands; this pins what is inside it, which + * is the half that proof deliberately does not reach. + */ +_Static_assert(sizeof(linux_siginfo_t) == 128, "siginfo_t is 128 bytes"); +_Static_assert(sizeof(linux_stack_t) == 24, "stack_t is 24 bytes"); +_Static_assert(offsetof(linux_sigcontext_t, regs) == 8, "sigcontext.regs"); +_Static_assert(offsetof(linux_sigcontext_t, sp) == 256, "sigcontext.sp"); +_Static_assert(offsetof(linux_sigcontext_t, pc) == 264, "sigcontext.pc"); +_Static_assert(offsetof(linux_sigcontext_t, pstate) == 272, + "sigcontext.pstate"); +_Static_assert(offsetof(linux_sigcontext_t, __reserved) == 288, + "sigcontext.__reserved is 16-byte aligned, so 288 not 280"); +_Static_assert(offsetof(linux_ucontext_t, uc_link) == 8, "ucontext.uc_link"); +_Static_assert(offsetof(linux_ucontext_t, uc_stack) == 16, "ucontext.uc_stack"); +_Static_assert(offsetof(linux_ucontext_t, uc_sigmask) == 40, + "ucontext.uc_sigmask"); +_Static_assert(offsetof(linux_ucontext_t, uc_mcontext) == 176, + "ucontext.uc_mcontext is 16-byte aligned, so 176 not 168"); +_Static_assert(offsetof(linux_rt_sigframe_t, uc) == 128, + "rt_sigframe.uc follows a 128-byte siginfo"); + /* RT signal queue. Maximum queued instances per RT signal. POSIX says at least * _POSIX_SIGQUEUE_MAX (32); Linux defaults to ~1024 per user. */ @@ -191,6 +227,7 @@ typedef struct { */ bool std_info_valid[LINUX_SIGRTMIN - 1]; signal_rt_info_t std_info[LINUX_SIGRTMIN - 1]; + /* RT signal queue: count of pending instances per signal. Standard signals * (1-31) use the pending bitmask plus std_info[]. RT signals (32-64) are * queued: each instance is tracked separately. @@ -315,9 +352,11 @@ void signal_set_shim_globals_guest(guest_t *g); */ int signal_deliver(hv_vcpu_t vcpu, guest_t *g, int *exit_code); -/* Return and clear the Linux wait-format status recorded when the current - * vCPU thread terminated because of a signal. Returns zero after a normal - * syscall exit or when no fatal signal was delivered. +/* Return and clear the Linux wait-format status recorded when the current vCPU + * thread terminated because of a signal. + * + * Returns zero after a normal syscall exit or when no fatal signal was + * delivered. */ int signal_take_termination_wait_status(void); @@ -368,13 +407,13 @@ int64_t signal_rt_sigsuspend(guest_t *g, /* Handle rt_sigpending (SYS 136). */ int64_t signal_rt_sigpending(guest_t *g, uint64_t set_gva, uint64_t sigsetsize); -/* Handle rt_sigtimedwait (SYS 137). - * Synchronously consume a pending signal whose number is in *set*. - * info_gva (may be 0): if non-zero, populate the guest siginfo_t there. - * timeout_gva (may be 0): if zero, block indefinitely; otherwise block for - * at most the specified duration. Returns the signal number on success, - * -EAGAIN if the timeout expired with no matching signal, or -EINTR if an - * unrelated signal arrived while waiting. +/* Handle rt_sigtimedwait (SYS 137). Synchronously consume a pending signal + * whose number is in *set*. info_gva (may be 0): if non-zero, populate the + * guest siginfo_t there. timeout_gva (may be 0): if zero, block indefinitely; + * otherwise block for at most the specified duration. + * + * Returns the signal number on success, -EAGAIN if the timeout expired with no + * matching signal, or -EINTR if an unrelated signal arrived while waiting. */ int64_t signal_rt_sigtimedwait(guest_t *g, uint64_t set_gva, diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 421c3457..c8f027fe 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -66,6 +66,8 @@ #include "syscall/signal.h" #include "syscall/sys.h" #include "syscall/sysvipc.h" +#include "proved/timespec.h" + #include "syscall/time.h" #include "core/shim-globals.h" @@ -2126,9 +2128,16 @@ static int64_t sc_epoll_pwait2(guest_t *g, linux_timespec_t ts; if (guest_read_small(g, x3, &ts, sizeof(ts)) < 0) return -LINUX_EFAULT; - timeout_ms = (ts.tv_sec > 2000000) - ? -1 - : (int) (ts.tv_sec * 1000 + ts.tv_nsec / 1000000); + + /* Same conversion as ppoll and pselect6, for the same three reasons: a + * sub-millisecond timeout must not truncate to a spin, a negative field + * must be EINVAL rather than a negative timeout the wait path reads + * back as "no timeout", and a huge tv_sec must clamp rather than become + * an infinite wait. + */ + if (!linux_timespec_valid(&ts)) + return -LINUX_EINVAL; + timeout_ms = syscall_timeout_ms_or_forever(ts.tv_sec, ts.tv_nsec); } return sys_epoll_pwait(g, (int) x0, x1, (int) x2, timeout_ms, x4); } diff --git a/src/syscall/time.c b/src/syscall/time.c index cf316bf2..ffc471fe 100644 --- a/src/syscall/time.c +++ b/src/syscall/time.c @@ -19,6 +19,9 @@ #include "core/vdso.h" #include "runtime/thread.h" /* current_thread, guest_tid */ + +#include "proved/timespec.h" + #include "syscall/linux-wire.h" #include "syscall/internal.h" #include "syscall/proc.h" /* proc_exit_group_requested, proc_get_pid */ @@ -61,25 +64,17 @@ _Static_assert(sizeof(struct timespec) == sizeof(linux_timespec_t), _Static_assert(sizeof(struct timeval) == sizeof(linux_timeval_t), "host and guest timeval must match on LP64"); +_Static_assert(NSEC_PER_SEC == TIMESPEC_NSEC_PER_SEC, + "the proved conversions must use this tree's nanosecond scale"); + bool linux_timespec_valid(const linux_timespec_t *ts) { - if (ts->tv_sec < 0) - return false; - return ts->tv_nsec >= 0 && ts->tv_nsec < NSEC_PER_SEC; + return timespec_valid(ts->tv_sec, ts->tv_nsec) != 0; } int64_t linux_timespec_to_ns_sat(const linux_timespec_t *ts) { - if (ts->tv_sec < 0) - return 0; - - const int64_t max_sec = INT64_MAX / NSEC_PER_SEC; - const int64_t max_nsec = INT64_MAX % NSEC_PER_SEC; - if (ts->tv_sec > max_sec || - (ts->tv_sec == max_sec && ts->tv_nsec > max_nsec)) - return INT64_MAX; - - return ts->tv_sec * NSEC_PER_SEC + ts->tv_nsec; + return timespec_to_ns_sat(ts->tv_sec, ts->tv_nsec); } static int64_t host_timespec_to_ns_sat(const struct timespec *ts) diff --git a/tests/test-gva-contracts.c b/tests/test-gva-contracts.c index 76518857..59208d5a 100644 --- a/tests/test-gva-contracts.c +++ b/tests/test-gva-contracts.c @@ -1,5 +1,5 @@ /* - * gva-math.h call-site precondition checks + * proved/gva.h call-site precondition checks * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 @@ -24,7 +24,7 @@ #include #include -#include "core/gva-math.h" +#include "proved/gva.h" #ifdef ELFUSE_CONTRACT_ASSERT diff --git a/tests/test-poll.c b/tests/test-poll.c index 2fd9897c..46453fcb 100644 --- a/tests/test-poll.c +++ b/tests/test-poll.c @@ -111,6 +111,84 @@ int main(void) FAIL("pselect failed"); } + /* A sub-millisecond ppoll timeout must actually wait. Converting it to + * poll(2) milliseconds by truncation yields poll(0), which returns + * immediately, so a guest polling with a 500 us timeout spins at full CPU + * instead of sleeping. Linux rounds the remainder up. + */ + TEST("ppoll waits out a sub-millisecond timeout"); + { + struct pollfd pfd = {.fd = -1, .events = POLLIN, .revents = 0}; + struct timespec ts = {.tv_sec = 0, .tv_nsec = 500000}; /* 500 us */ + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + int ret = ppoll(&pfd, 1, &ts, NULL); + clock_gettime(CLOCK_MONOTONIC, &t1); + int64_t elapsed_ns = + (t1.tv_sec - t0.tv_sec) * 1000000000LL + (t1.tv_nsec - t0.tv_nsec); + if (ret != 0) + FAIL("ppoll did not time out"); + else if (elapsed_ns < 200000) + FAIL("ppoll returned before its sub-millisecond timeout"); + else + PASS(); + } + + /* Bits above nfds in the last fd_set word must be ignored. Linux bounds its + * per-word scan by nfds (fs/select.c); a walk that iterates whole words + * instead polls an fd the caller never asked about, and fails with EBADF + * when that fd is not open. nfds is deliberately not a multiple of 64 so + * the last word is partial. + */ + TEST("pselect ignores fd_set bits above nfds"); + { + int fds[2]; + if (pipe(fds) < 0) { + FAIL("pipe failed"); + } else { + int closed = dup(fds[0]); + if (closed < 0) { + FAIL("dup failed"); + } else if (closed >= FD_SETSIZE) { + FAIL("dup returned an fd outside the fd_set"); + } else { + /* nfds is the fd itself, so the set bit sits at index nfds: the + * first bit the kernel must ignore, and always inside the last + * word the walk reads. Deriving nfds from the fd instead + * (clamped to some constant) lets the bit land in a word + * pselect never reads at all, and the test then passes for a + * reason unrelated to what it guards. + * + * A multiple of 64 is the one value that does not work: the + * word holding bit nfds is then past the end of the read, so + * step to the next fd, which cannot also be a multiple of 64. + */ + if (closed % 64 == 0) { + int next = dup(fds[0]); + if (next >= 0) { + close(closed); + closed = next; + } + } + int nfds = closed; + close(closed); + fd_set rd; + FD_ZERO(&rd); + FD_SET(closed, &rd); + struct timespec ts = {.tv_sec = 0, .tv_nsec = 0}; + int ret = pselect(nfds, &rd, NULL, NULL, &ts, NULL); + if (closed % 64 == 0) + FAIL("could not place the bit inside the last word"); + else if (ret == 0) + PASS(); + else + FAIL("pselect honored a bit above nfds"); + } + close(fds[0]); + close(fds[1]); + } + } + /* Test kill(getpid(), 0): process existence check */ TEST("kill(getpid, 0)"); { @@ -225,6 +303,7 @@ int main(void) int saved = errno; pthread_join(sender, NULL); errno = saved; + /* The handler running is the hard requirement (got_usr1); * that alone proves the signal reached a thread blocked in * a host read(). The read outcome is accepted either way: @@ -282,6 +361,7 @@ int main(void) int saved = errno; pthread_join(sender, NULL); errno = saved; + /* got_usr1 is the hard requirement: it proves the signal * reached a thread blocked in a host recv(). The recv * outcome is accepted either way (restarted read returns From fbc1f19a5f8c029a5f10bd6a9897f8340c03e5c4 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:33:57 +0800 Subject: [PATCH 04/15] Give each wait path the timeout conversion recvmmsg and epoll_pwait2 shared syscall_timeout_ms_or_forever, which maps an effectively-infinite request onto poll's -1. That mapping earns its keep in epoll_pwait2, where a negative timeout selects the 200 ms re-arm loop that re-checks exit_group, pending signals, futex interrupts and pty hangup. recvmmsg has no such loop: it waits in a single poll, so -1 parked it there with nothing to wake it, on a call the guest had asked to bound. It takes the saturating conversion now, which is still far past any real timeout but is actually reached. --- src/syscall/internal.h | 18 +++++++++--------- src/syscall/net-msg.c | 15 +++++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/syscall/internal.h b/src/syscall/internal.h index 37c15ad1..ac13e04a 100644 --- a/src/syscall/internal.h +++ b/src/syscall/internal.h @@ -467,12 +467,8 @@ static inline int64_t host_fd_ref_open_io_gen(guest_fd_t guest_fd, return 0; } -/* A guest timeout above this many seconds means "wait indefinitely", and the - * wait paths spell indefinite as timeout_ms = -1. The comparison is strict, so - * this exact value is still converted; the cutoff carries no meaning of its own - * beyond being far past any real timeout, and one second either side of it is - * equally unreachable. Both callers spelled it strictly before this helper - * existed, and moving the boundary would change what they return. +/* A guest timeout at or above this many seconds means "wait indefinitely", and + * the wait path spells indefinite as timeout_ms = -1. * * That -1 is load-bearing, not a rounding convenience. sys_epoll_pwait reads * timeout_ms < 0 as has_timeout = false, which selects the 200 ms re-arm loop @@ -489,12 +485,16 @@ static inline int64_t host_fd_ref_open_io_gen(guest_fd_t guest_fd, /* Guest timespec to a poll(2)/kevent millisecond timeout, mapping an * effectively-infinite request onto the -1 that selects the interruptible path. - * Two callers need exactly this (epoll_pwait2 and recvmmsg); ppoll and pselect6 - * do not, because neither ever spelled a timespec as indefinite. + * + * epoll_pwait2 is the only caller, and the mapping is only safe there. ppoll + * and pselect6 never spelled a timespec as indefinite, and recvmmsg waits in a + * single poll with nothing to re-arm it, so -1 would strand it rather than + * making it interruptible. A caller without a re-arm loop wants the saturating + * conversion instead. */ static inline int syscall_timeout_ms_or_forever(int64_t sec, int64_t nsec) { - if (sec > SYSCALL_TIMEOUT_FOREVER_SEC) + if (sec >= SYSCALL_TIMEOUT_FOREVER_SEC) return -1; return timespec_to_poll_ms(sec, nsec); } diff --git a/src/syscall/net-msg.c b/src/syscall/net-msg.c index 45868e96..d8f3a311 100644 --- a/src/syscall/net-msg.c +++ b/src/syscall/net-msg.c @@ -1069,13 +1069,16 @@ int64_t sys_recvmmsg(guest_t *g, return -LINUX_EINVAL; } - /* Same conversion as ppoll, pselect6 and epoll_pwait2. Truncating - * turned a sub-millisecond timeout into poll(0) and an immediate - * EAGAIN, and the old tv_sec ceiling turned a large finite timeout - * into an infinite wait. The EINVAL half is the guard above. + /* Same conversion as ppoll and pselect6. Truncating turned a + * sub-millisecond timeout into poll(0) and an immediate EAGAIN. + * + * Deliberately the finite conversion, unlike epoll_pwait2: the + * wait below is one poll with nothing to re-arm it, so a -1 here + * would block forever with no way back out, on a call the guest + * asked to bound. timespec_to_poll_ms saturates instead, which is + * still far past any real timeout but is reached. */ - int timeout_ms = - syscall_timeout_ms_or_forever(ts.tv_sec, ts.tv_nsec); + int timeout_ms = timespec_to_poll_ms(ts.tv_sec, ts.tv_nsec); struct pollfd pfd = {.fd = host_ref.fd, .events = POLLIN}; int pr = poll(&pfd, 1, timeout_ms); host_fd_ref_close(&host_ref); From 62d8b65b42d34a5d8de93ed3986e02fbaf5b5770 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:34:34 +0800 Subject: [PATCH 05/15] Give a pty's slave accounting one home pty_keepalive_table holds a row per master host fd, but three of the fields on it describe the pty rather than the master: the guest slave count, the seen flag, and the shared segment. Aliased masters get a row each -- dup, dup2, F_DUPFD, an SCM_RIGHTS adopt, a fork restore -- while the counting paths took "the first row in table order for this minor". So a count could be recorded against one row and handed back to the segment by another row's destruction. Closing one alias of a master then reported a hangup with the slave still open: poll returned POLLHUP and read returned EIO where Linux gives EAGAIN. Ordering the rows the other way lost the hangup instead, because the release found a fresh alias at a lower index holding no count and stopped there. A slave open that failed after the intercept had already recorded it left the count behind for good, and the master could never hang up again; O_DIRECTORY on a slave path reaches that, since the host open succeeds and only the type check rejects it. Every counting path now resolves the same accounting home for a minor: the row already holding a count, else one that has seen a slave, else the first. Ranking a row that has seen a slave above one that has not is what stops a new alias at a lower index from displacing a home whose count has just fallen to zero. A master close hands its count to another row for the same pty, and keeps the row as a master-less home when there is none. An heir mapping no segment cannot take a count that was contributed to one; in the other direction the segment learns about the count as it arrives. Reusing a row for the minor it already describes keeps its accounting rather than detaching it, except after a fresh host allocation, where the minor only returns once every fd on it is gone. The row is released once its last counted slave closes, which is what gives back the retained slave fd that pins the macOS tty. Nothing did before, so the pin outlived every use of it. That is worse than a leak: it keeps the tty half-alive after the master is torn down, and a slave open against it blocks rather than failing, so a child consulting its own pts path after the pty is gone waits forever. One case this narrows deliberately -- a child that closes every slave it holds and then reopens the path gets ENOENT, where Linux would allow it while some process still holds the master. The child cannot see that master, so its own count is the only signal it has. Two lifetime bugs sat alongside. pty_keepalive_register reported through errno, which its insert path cannot do honestly: pty_shared_attach joins an existing segment by letting an O_EXCL create fail with EEXIST and reopening, and nothing clears errno after, so a successful insert during fork restore came back as EEXIST and the caller closed an fd the table had already recorded. The number was then reused, and the guest's next open of its slave either failed or aliased an unrelated file. It returns the PTY_REG_* status instead. Separately, pty_open_slave consumed a fork-child's one-shot mapping whether the open succeeded or failed, so a child that reopened its slave, stat'd its own pts path, or read /dev/pts after the first open got ENOENT while still holding a live slave on that pty. The mapping is given up only on failure now. /dev/pts is served from a staging directory of placeholder files so getdents64 lists the right names, and the open and stat intercepts key on an absolute path, so anything measured against a descriptor for it reached a placeholder: fstatat reported a 0444 regular file rather than a character device, and openat returned that file rather than the slave. Stamping the guest spelling puts relative calls back through the intercept, for a directory fd and for a cwd alike, which is where the pty semantics and the accounting live. proc_pty_forget_host_fd replaces the four places that hand-paired the master and slave cleanups. A host fd that never becomes a guest fd has to leave both tables, the call site cannot tell which one holds it, and calling one alone is silent either way: a leaked keepalive slave, or a phantom count that suppresses the hangup for good. --- src/runtime/procemu-pty.c | 367 ++++++++++++++++++++++++++------------ src/runtime/procemu.h | 10 ++ src/syscall/fdtable.c | 16 +- src/syscall/fs.c | 47 +++-- src/syscall/path.c | 7 +- tests/test-pty.c | 281 +++++++++++++++++++++++++---- 6 files changed, 552 insertions(+), 176 deletions(-) diff --git a/src/runtime/procemu-pty.c b/src/runtime/procemu-pty.c index e8e58a76..96284d8f 100644 --- a/src/runtime/procemu-pty.c +++ b/src/runtime/procemu-pty.c @@ -71,9 +71,9 @@ * the path mapping past close, the subsequent /dev/pts/N open in the child * loses its translation and fails with ENOENT even though the parent still * holds the master and the macOS slave node is openable. Those stale entries - * keep the received slave fd until the first translated open attempt, then - * expire before the minor can be reused for an unrelated host tty. Ordinary - * local master closes clear the mapping immediately. + * keep the received slave fd, which pins the macOS tty so the mapping cannot + * come to name an unrelated minor, and give it up once a translated open has + * failed. Ordinary local master closes clear the mapping immediately. */ #define PTY_KEEPALIVE_MAX 256 @@ -365,9 +365,127 @@ static int pty_keepalive_find_master_locked(int master_host_fd) return -1; } +/* Whether row i describes pty minor pts. A fully cleared row keeps neither a + * path nor a minor, so the path test is what stops it matching minor 0. + */ +static bool pty_row_is_pts_locked(int i, uint32_t pts) +{ + return pty_keepalive_table[i].linux_pts_num == pts && + pty_keepalive_table[i].slave_path[0] != '\0'; +} + +/* The one row that carries a pty's slave accounting: whichever row already + * holds a count, else the first row for that minor. Aliased masters (dup, + * SCM_RIGHTS adopt, fork restore) each get a row of their own, but the slaves + * belong to the pty rather than to any one master fd, so every counting path + * has to agree on a single home. + * + * except_slot excludes a row from the answer, which the master-close path needs + * to find an heir for a row that still holds the count it is giving up. + */ +static int pty_account_row_locked(uint32_t pts, int except_slot) +{ + int seen = -1, first = -1; + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (i == except_slot || !pty_row_is_pts_locked(i, pts)) + continue; + if (pty_keepalive_table[i].guest_slave_count > 0) + return i; + + /* Below a live count, a row that has seen a slave outranks one that + * never did. Without that, an alias registered at a lower index takes + * the home from a row whose count has fallen to zero, and the + * no-segment hangup test reads guest_slave_seen off the wrong row and + * never reports the hangup. + */ + if (seen < 0 && pty_keepalive_table[i].guest_slave_seen) + seen = i; + if (first < 0) + first = i; + } + return seen >= 0 ? seen : first; +} + +/* Record on the pty's shared segment that it has had a slave, through whichever + * row still maps it. Every row for a minor attaches the same segment by + * slave_path, so the heir taking over the accounting need not be the row that + * holds the mapping, and pty_slot_hung_up_locked reads the segment rather than + * any one row's copy. + */ +static void pty_shared_mark_seen_locked(uint32_t pts) +{ + for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + if (pty_row_is_pts_locked(i, pts) && pty_keepalive_table[i].shared) { + atomic_store(&pty_keepalive_table[i].shared->seen, 1); + return; + } + } +} + +/* Defined below, next to the one-shot open it serves. */ +static int pty_keepalive_retire_stale_locked(int slot); + static int pty_keepalive_clear_slot_locked(int slot) { int slave = pty_keepalive_table[slot].slave_host_fd; + + /* Slaves the guest still holds outlive this master. Hand the accounting to + * another row for the same pty when one exists; otherwise keep this row as + * the pty's master-less home. Returning the count to the segment here would + * report a hangup with those slaves open. + */ + if (pty_keepalive_table[slot].guest_slave_count > 0) { + int heir = pty_account_row_locked( + pty_keepalive_table[slot].linux_pts_num, slot); + + /* An heir that maps no segment cannot take over a count that was + * contributed to one: its releases would not reach the segment the + * other processes read. + */ + if (heir >= 0 && pty_keepalive_table[slot].shared && + !pty_keepalive_table[heir].shared) + heir = -1; + + if (heir < 0) { + /* Nobody to take it, so this row stays as the pty's master-less + * home until its last slave closes. + */ + pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; + return pty_keepalive_retire_stale_locked(slot); + } + + /* A count that was never in a segment becomes one that is, so the + * segment has to learn about it here. Otherwise each of those slaves + * decrements on close a total it was never added to, and the count goes + * negative under the other processes reading it. + */ + if (!pty_keepalive_table[slot].shared && + pty_keepalive_table[heir].shared) { + atomic_fetch_add(&pty_keepalive_table[heir].shared->slave_count, + pty_keepalive_table[slot].guest_slave_count); + } + pty_shared_mark_seen_locked(pty_keepalive_table[slot].linux_pts_num); + + pty_keepalive_table[heir].guest_slave_count += + pty_keepalive_table[slot].guest_slave_count; + pty_keepalive_table[heir].guest_slave_seen = true; + pty_keepalive_table[slot].guest_slave_count = 0; + } else if (pty_keepalive_table[slot].guest_slave_seen) { + /* No count left, but the fact that this pty ever had a slave is what + * the no-segment hangup test reads. Hand it on so an alias does not + * answer "never had one" for a pty that has already hung up. + */ + int heir = pty_account_row_locked( + pty_keepalive_table[slot].linux_pts_num, slot); + if (heir >= 0) + pty_keepalive_table[heir].guest_slave_seen = true; + + /* Not conditional on the heir mapping the segment: the row that does + * may be a third alias, and the hangup test reads the segment rather + * than any row's local copy. + */ + pty_shared_mark_seen_locked(pty_keepalive_table[slot].linux_pts_num); + } pty_shared_detach(pty_keepalive_table[slot].shared, pty_keepalive_table[slot].slave_path, pty_keepalive_table[slot].guest_slave_count); @@ -497,18 +615,28 @@ static int pty_keepalive_register_locked(int master_host_fd, return PTY_REG_FULL; } - /* Reusing a stale-path slot inherits its mapping; hand it back before the + /* Reusing a row for a different pty hands its mapping back before the * fields below are overwritten, or the reference and any slaves it still * counted would be stranded in the segment. + * + * A row being reused for the minor it already describes keeps that pty's + * accounting: the slaves counted on it are still open. A freshly allocated + * host pty is the exception -- the minor only comes back once every fd on + * it is gone, so a leftover count there is dead state. */ - pty_shared_detach(pty_keepalive_table[slot].shared, - pty_keepalive_table[slot].slave_path, - pty_keepalive_table[slot].guest_slave_count); - pty_keepalive_table[slot].shared = NULL; + bool same_pty = !fresh_segment && + pty_keepalive_table[slot].slave_path[0] != '\0' && + pty_keepalive_table[slot].linux_pts_num == linux_pts_num; + if (!same_pty) { + pty_shared_detach(pty_keepalive_table[slot].shared, + pty_keepalive_table[slot].slave_path, + pty_keepalive_table[slot].guest_slave_count); + pty_keepalive_table[slot].shared = NULL; + pty_keepalive_table[slot].guest_slave_count = 0; + pty_keepalive_table[slot].guest_slave_seen = false; + } pty_keepalive_table[slot].master_host_fd = master_host_fd; - pty_keepalive_table[slot].guest_slave_count = 0; - pty_keepalive_table[slot].guest_slave_seen = false; if (pty_keepalive_table[slot].slave_host_fd >= 0 && pty_keepalive_table[slot].slave_host_fd != slave_host_fd) close(pty_keepalive_table[slot].slave_host_fd); @@ -528,17 +656,21 @@ static int pty_keepalive_register_locked(int master_host_fd, * would split the aliases onto separate counters, so slaves opened through * one would be invisible to the other and the hangup would be lost. */ - pty_keepalive_table[slot].shared = - pty_shared_attach(pty_keepalive_table[slot].slave_path, fresh_segment); + if (!pty_keepalive_table[slot].shared) + pty_keepalive_table[slot].shared = pty_shared_attach( + pty_keepalive_table[slot].slave_path, fresh_segment); return PTY_REG_INSERTED; } /* Lock-acquiring convenience wrapper used by the open-time and fork-restore * paths where atomicity with fd_table is not required. * - * Returns 0 on success (including PTY_REG_EXISTS, in which case the caller - * should close its own redundant slave_host_fd), -1 with errno set on - * table-full (ENOSPC). + * Returns the PTY_REG_* status. It used to report through errno instead, which + * the insert path could not do honestly: pty_shared_attach reaches an existing + * segment by letting an O_EXCL create fail with EEXIST and reopening, and + * nothing after that clears errno. A successful insert during fork restore + * therefore returned with errno still EEXIST, and the caller read that as "a + * duplicate, drop the slave" and closed an fd the table had already recorded. */ static int pty_keepalive_register(int master_host_fd, int slave_host_fd, @@ -552,13 +684,7 @@ static int pty_keepalive_register(int master_host_fd, master_host_fd, slave_host_fd, linux_pts_num, slave_path, stale_open_once, fresh_segment, NULL); pthread_mutex_unlock(&pty_keepalive_lock); - if (rc == PTY_REG_FULL) { - errno = ENOSPC; - return -1; - } - if (rc == PTY_REG_EXISTS) - errno = EEXIST; - return 0; + return rc; } uint32_t proc_pty_master_pts_num(int master_host_fd) @@ -771,43 +897,40 @@ static void pty_guest_slave_table_init_once(void) done = true; } -/* Retire a recorded slave fd and credit its master. Caller holds the lock. */ -static void pty_guest_slave_release_locked(int slave_host_fd) +/* Retire a recorded slave fd and credit its master. Caller holds the lock. + * Returns any retained keepalive slave fd the caller must close. + */ +static int pty_guest_slave_release_locked(int slave_host_fd) { for (int i = 0; i < PTY_GUEST_SLAVE_MAX; i++) { if (pty_guest_slave_table[i].slave_host_fd != slave_host_fd) continue; uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; pty_guest_slave_table[i].slave_host_fd = PTY_KEEPALIVE_FREE; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - /* A slot whose master already closed still owns this slave's - * accounting: the guest can drop the master and keep the slave as - * its stdio, and that slave has to be able to give its count back. - * Matching on the retained pts number covers both states; a fully - * cleared slot has neither a path nor a mapping and cannot match. + int k = pty_account_row_locked(pts_num, -1); + if (k >= 0 && pty_keepalive_table[k].guest_slave_count > 0) { + pty_keepalive_table[k].guest_slave_count--; + pty_diag( + "pty: -slave pts=%u hostfd=%d local=%d shared=%d", pts_num, + slave_host_fd, pty_keepalive_table[k].guest_slave_count, + pty_keepalive_table[k].shared + ? atomic_load(&pty_keepalive_table[k].shared->slave_count) - + 1 + : -1); + if (pty_keepalive_table[k].shared) + atomic_fetch_sub(&pty_keepalive_table[k].shared->slave_count, + 1); + + /* A master-less home with nothing left to account for can go now + * rather than at process teardown. */ - if (pty_keepalive_table[k].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[k].linux_pts_num != pts_num) - continue; - if (pty_keepalive_table[k].guest_slave_count > 0) { - pty_keepalive_table[k].guest_slave_count--; - pty_diag( - "pty: -slave pts=%u hostfd=%d local=%d shared=%d", pts_num, - slave_host_fd, pty_keepalive_table[k].guest_slave_count, - pty_keepalive_table[k].shared - ? atomic_load( - &pty_keepalive_table[k].shared->slave_count) - - 1 - : -1); - if (pty_keepalive_table[k].shared) - atomic_fetch_sub( - &pty_keepalive_table[k].shared->slave_count, 1); - } - break; + if (pty_keepalive_table[k].guest_slave_count == 0 && + pty_keepalive_table[k].master_host_fd == PTY_KEEPALIVE_FREE) + return pty_keepalive_clear_slot_locked(k); } break; } + return -1; } /* Put a slave fd on this process's books and credit its master. Caller holds @@ -828,19 +951,16 @@ static void pty_guest_slave_record_locked(int slave_host_fd, continue; pty_guest_slave_table[i].slave_host_fd = slave_host_fd; pty_guest_slave_table[i].linux_pts_num = linux_pts_num; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - /* Match the rule the release path uses: a slot whose master has - * already closed still owns this pty's accounting. A fork-restored - * child routinely drops its copy of the master and only then opens - * /dev/pts/N, and requiring a live master here left that slave - * credited to nobody -- so the parent, still holding the master, - * never learned the shell had one. A fully cleared slot keeps - * neither a path nor a mapping and cannot match. - */ - if (pty_keepalive_table[k].slave_path[0] == '\0') - continue; - if (pty_keepalive_table[k].linux_pts_num != linux_pts_num) - continue; + + /* The accounting home, not merely the first row for this pty. A slot + * whose master has already closed still owns the count: a fork-restored + * child routinely drops its copy of the master and only then opens + * /dev/pts/N, and requiring a live master here left that slave credited + * to nobody, so the parent still holding the master never learned the + * shell had one. + */ + int k = pty_account_row_locked(linux_pts_num, -1); + if (k >= 0) { pty_keepalive_table[k].guest_slave_count++; pty_keepalive_table[k].guest_slave_seen = true; pty_diag( @@ -858,7 +978,6 @@ static void pty_guest_slave_record_locked(int slave_host_fd, &pty_keepalive_table[k].shared->slave_count, 1); atomic_store(&pty_keepalive_table[k].shared->seen, 1); } - break; } break; } @@ -878,9 +997,11 @@ static void pty_note_guest_slave(int slave_host_fd, * the host fd without passing through the close hooks; retiring the stale * slot on reuse keeps that from inflating an unrelated pty's count. */ - pty_guest_slave_release_locked(slave_host_fd); + int slave = pty_guest_slave_release_locked(slave_host_fd); pty_guest_slave_record_locked(slave_host_fd, linux_pts_num, bump_shared); pthread_mutex_unlock(&pty_keepalive_lock); + if (slave >= 0) + close(slave); } void proc_pty_note_guest_slave(int slave_host_fd, uint32_t linux_pts_num) @@ -910,16 +1031,15 @@ void proc_pty_fork_parent_note_inherited(void) if (pty_guest_slave_table[i].slave_host_fd == PTY_KEEPALIVE_FREE) continue; uint32_t pts_num = pty_guest_slave_table[i].linux_pts_num; - for (int k = 0; k < PTY_KEEPALIVE_MAX; k++) { - if (pty_keepalive_table[k].master_host_fd == PTY_KEEPALIVE_FREE) - continue; - if (pty_keepalive_table[k].linux_pts_num != pts_num) - continue; - if (pty_keepalive_table[k].shared) - atomic_fetch_add(&pty_keepalive_table[k].shared->slave_count, - 1); - break; - } + + /* The accounting home, not the first row with a live master. Picking + * differently here would credit the child's inherited slave to one row + * while pty_guest_slave_release_locked takes it back off another, and + * the two rows need not share a segment. + */ + int k = pty_account_row_locked(pts_num, -1); + if (k >= 0 && pty_keepalive_table[k].shared) + atomic_fetch_add(&pty_keepalive_table[k].shared->slave_count, 1); } pthread_mutex_unlock(&pty_keepalive_lock); } @@ -947,8 +1067,10 @@ void proc_pty_dup_guest_slave_locked(int src_slave_host_fd, * original left the count at zero with three references still open -- the * master then reported a hangup with the shell still running. */ - pty_guest_slave_release_locked(dst_slave_host_fd); + int slave = pty_guest_slave_release_locked(dst_slave_host_fd); pty_guest_slave_record_locked(dst_slave_host_fd, pts_num, true); + if (slave >= 0) + close(slave); } void proc_pty_release_process_slaves(void) @@ -1066,8 +1188,16 @@ void proc_pty_slave_fd_closed(int host_fd) return; pty_keepalive_lock_acquire(); pty_guest_slave_table_init_once(); - pty_guest_slave_release_locked(host_fd); + int slave = pty_guest_slave_release_locked(host_fd); pthread_mutex_unlock(&pty_keepalive_lock); + if (slave >= 0) + close(slave); +} + +void proc_pty_forget_host_fd(int host_fd) +{ + proc_pty_close_keepalive(host_fd); + proc_pty_slave_fd_closed(host_fd); } /* Whether this slot's pty has no guest slave left. Reads the shared segment @@ -1079,12 +1209,21 @@ static bool pty_slot_hung_up_locked(int slot) { pty_shared_t *sh = pty_keepalive_table[slot].shared; bool hung_up; - if (sh) + if (sh) { hung_up = atomic_load(&sh->seen) != 0 && atomic_load(&sh->slave_count) <= 0; - else - hung_up = pty_keepalive_table[slot].guest_slave_seen && - pty_keepalive_table[slot].guest_slave_count == 0; + } else { + /* No segment, so the counters are this process's own -- and they live + * on the pty's accounting home, which for an aliased master is not this + * row. Reading them here is what the shared case gets for free. + */ + int home = + pty_account_row_locked(pty_keepalive_table[slot].linux_pts_num, -1); + if (home < 0) + home = slot; + hung_up = pty_keepalive_table[home].guest_slave_seen && + pty_keepalive_table[home].guest_slave_count == 0; + } /* Only on the way to reporting one: the negative answer is the steady state * and every poll would log it. This subsystem spans processes, so without a @@ -1171,10 +1310,14 @@ int pty_open_slave(uint32_t linux_pts_num, int linux_flags) return -1; } - /* Stale fork-child entries are one-shot. The retained slave fd pins the - * macOS tty while we translate the close-before-open sequence, preventing - * the cached path from resolving to a reused unrelated minor. Regardless of - * open success, consume the stale mapping before returning. + /* The retained slave fd pins the macOS tty while this translates the + * close-before-open sequence, so the cached path cannot resolve to a reused + * unrelated minor. The mapping is consumed only when the open failed: + * keeping it while the pin is still doing its job is what lets the child + * open its slave more than once, and stat it, and see it in /dev/pts. + * Retiring on success made the entry one-shot, so a second open, a stat of + * the child's own pts path, or a readdir after the first open answered + * ENOENT while the child still held a live slave on that pty. */ size_t len = strlen(pty_keepalive_table[stale_hit].slave_path); if (len >= sizeof(host_path)) { @@ -1188,7 +1331,7 @@ int pty_open_slave(uint32_t linux_pts_num, int linux_flags) memcpy(host_path, pty_keepalive_table[stale_hit].slave_path, len + 1); fd = open(host_path, oflags); int saved = errno; - for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { + for (int i = 0; fd < 0 && i < PTY_KEEPALIVE_MAX; i++) { if (pty_keepalive_table[i].master_host_fd != PTY_KEEPALIVE_FREE) continue; if (!pty_keepalive_table[i].stale_open_once) @@ -1222,9 +1365,9 @@ int pty_open_pts_dir(int linux_flags) pty_keepalive_lock_acquire(); - /* Enumerate live masters and fork-child one-shot stale entries. The stale - * entries retain a slave fd until the first open attempt consumes them, so - * they cannot name a reused unrelated tty while they appear in readdir. + /* Enumerate live masters and fork-child stale entries. A stale entry holds + * its slave fd for as long as it is listed here, so it cannot name a reused + * unrelated tty while it appears in readdir. */ for (int i = 0; i < PTY_KEEPALIVE_MAX; i++) { if (pty_keepalive_table[i].slave_path[0] == '\0') @@ -1234,9 +1377,9 @@ int pty_open_pts_dir(int linux_flags) pty_keepalive_table[i].slave_host_fd < 0)) continue; - /* The recycle/reuse-by-pts_num invariant in - * pty_keepalive_register_locked keeps at most one entry per minor, so - * no de-duplication pass is needed here. + /* Aliased masters give one minor several rows, so the same number can + * come up more than once. A duplicate only re-creates the placeholder + * file it already made, which readdir reports once either way. */ pts_nums[pts_count++] = pty_keepalive_table[i].linux_pts_num; } @@ -1324,14 +1467,14 @@ void proc_pty_close_keepalive(int master_host_fd) int slot = pty_keepalive_find_master_locked(master_host_fd); if (slot >= 0) { if (pty_keepalive_table[slot].stale_open_once) { - /* Fork-restored child entry: retain the slave fd and path for one - * /dev/pts/N open after close(master). pty_open_slave consumes and - * closes it on the first translated open attempt. Only the master - * goes away here. Any slave fd this process still holds stays open - * and keeps counting: closing the master does not close the slaves, - * and a terminal's child routinely drops its copy of the master - * while holding the slave as its stdio. Retiring the count here - * would report a hangup with the shell still running. The slaves + /* Fork-restored child entry: retain the slave fd and path so + * /dev/pts/N stays openable after close(master). pty_open_slave + * gives them up only once an open has failed. Only the master goes + * away here. Any slave fd this process still holds stays open and + * keeps counting: closing the master does not close the slaves, and + * a terminal's child routinely drops its copy of the master while + * holding the slave as its stdio. Retiring the count here would + * report a hangup with the shell still running. The slaves * decrement themselves as they close. */ pty_keepalive_table[slot].master_host_fd = PTY_KEEPALIVE_FREE; @@ -1432,15 +1575,15 @@ void proc_pty_restore_keepalive(int master_host_fd, /* Trust the parent's linux_pts_num verbatim instead of re-parsing * slave_path. The wire-format string is bounded to PTY_SLAVE_PATH_MAX - 1 * bytes; if a future macOS canonical form ever exceeded that, the parent - * would have truncated and reparsing here would yield the wrong number. On - * EEXIST the child's fd_table-restore path replayed master_host_fd over a - * prior recv-keepalive entry; drop the redundant slave so it does not leak. + * would have truncated and reparsing here would yield the wrong number. + * + * Anything but PTY_REG_INSERTED means the child's fd_table-restore path + * replayed master_host_fd over a prior recv-keepalive entry, so the slave + * handed in here is redundant; drop it rather than leak it. */ - errno = 0; - if (pty_keepalive_register_recycled(master_host_fd, slave_host_fd, - linux_pts_num, slave_path, true, - /*fresh_segment=*/false) < 0 || - errno == EEXIST) + if (pty_keepalive_register_recycled( + master_host_fd, slave_host_fd, linux_pts_num, slave_path, true, + /*fresh_segment=*/false) != PTY_REG_INSERTED) goto drop; return; @@ -1494,14 +1637,14 @@ int pty_open_master(int linux_flags) close_keep_errno(master); return -1; } - errno = 0; /* The host just allocated this pty, so nothing live can be using a segment * under its name; any leftover is state a process died holding. */ - if (pty_keepalive_register_recycled(master, slave, linux_pts_num, - slave_path, false, - /*fresh_segment=*/true) < 0) { + int reg = pty_keepalive_register_recycled(master, slave, linux_pts_num, + slave_path, false, + /*fresh_segment=*/true); + if (reg == PTY_REG_FULL) { close(slave); close(master); errno = EMFILE; @@ -1513,7 +1656,7 @@ int pty_open_master(int linux_flags) * run proc_pty_close_keepalive). Drop the redundant slave so it does not * leak. */ - if (errno == EEXIST) + if (reg == PTY_REG_EXISTS) close(slave); return master; } diff --git a/src/runtime/procemu.h b/src/runtime/procemu.h index 634c38d2..8f575183 100644 --- a/src/runtime/procemu.h +++ b/src/runtime/procemu.h @@ -122,6 +122,16 @@ void proc_pty_note_guest_slave(int slave_host_fd, uint32_t linux_pts_num); */ void proc_pty_slave_fd_closed(int host_fd); +/* Drop a host fd from both pty side tables, master and slave. + * + * A host fd that goes away without becoming a guest fd has to leave both, and + * which of the two it is registered in is not known at the call site. Calling + * only one is silent: a leaked keepalive slave, or a phantom slave count that + * suppresses the master's hangup for good. Every path that closes a host fd + * outside fd_cleanup_entry wants this rather than either half. + */ +void proc_pty_forget_host_fd(int host_fd); + /* Count the pty slave fds a forked child inherited from its parent. * * The host-fd-to-pty mapping is per-process and does not cross the fork, so diff --git a/src/syscall/fdtable.c b/src/syscall/fdtable.c index 79febde5..4597a54a 100644 --- a/src/syscall/fdtable.c +++ b/src/syscall/fdtable.c @@ -751,17 +751,13 @@ void fd_cleanup_entry(int guest_fd, const fd_entry_t *snap) if (snap->cleanup) snap->cleanup(guest_fd); - /* Drop any /dev/ptmx keepalive slave fd paired with this host fd. Must - * happen before close(snap->host_fd) because the side table is keyed by the - * still-live host master fd. No-op for non-pty fds. + /* Drop this host fd from both pty side tables. Must happen before + * close(snap->host_fd): both are keyed by the still-live host fd. The + * master half stops the keepalive slave leaking past a /dev/ptmx close; the + * slave half is what lets the master see its last slave go, which only this + * accounting can tell since elfuse's own keepalive slave stays open. */ - proc_pty_close_keepalive(snap->host_fd); - - /* Mirror for the slave side: the master reports a hangup once the guest has - * closed every slave it held, which only this accounting can see -- - * elfuse's own keepalive slave stays open. No-op for other fds. - */ - proc_pty_slave_fd_closed(snap->host_fd); + proc_pty_forget_host_fd(snap->host_fd); /* Deregister any SIGIO/SIGURG readiness watch before the host fd closes. * Closing the fd auto-removes the knote too, but doing it explicitly avoids diff --git a/src/syscall/fs.c b/src/syscall/fs.c index dd7d8d8d..45975396 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -189,6 +189,21 @@ static bool resolve_virtual_path(const char *path, char *out, size_t out_size) return true; } + /* /dev/pts is served from a host staging directory holding one empty + * placeholder file per live slave, which is what makes getdents64 list the + * right names. The placeholders are names and nothing else: opening one + * yields a 0444 regular file rather than a tty, and the openat/fstatat + * intercepts key on an absolute path, so a descriptor opened on the + * directory used to reach them directly. Stamping the guest spelling lets + * resolve_proc_dirfd_path rebuild /dev/pts/N for a relative call measured + * against this descriptor, which puts it back through the intercept that + * opens the real slave and accounts for it. + */ + if (!strcmp(path, "/dev/pts") || !strcmp(path, "/dev/pts/")) { + str_copy_trunc(out, "/dev/pts", out_size); + return true; + } + if (strncmp(path, "/proc", 5) != 0) return false; @@ -557,7 +572,7 @@ int64_t sys_openat_path(guest_t *g, * leaks because nothing else has the master in fd_table. * proc_pty_close_keepalive is a no-op for other paths. */ - proc_pty_close_keepalive(intercepted); + proc_pty_forget_host_fd(intercepted); close_keep_errno(intercepted); return linux_errno(); } @@ -567,7 +582,7 @@ int64_t sys_openat_path(guest_t *g, intercepted, type, linux_flags, min_guest_fd, fd_cleanup_for_type(type), tx.intercept_path); if (guest_fd < 0) { - proc_pty_close_keepalive(intercepted); + proc_pty_forget_host_fd(intercepted); close_keep_errno(intercepted); return linux_errno(); } @@ -763,19 +778,11 @@ int64_t sys_close(int fd) int host_fd = -1; if (fd_close_regular_relaxed(fd, &host_fd)) { /* The fast path bypasses fd_cleanup_entry, so any side tables keyed by - * host_fd that the slow path drops must be drained here too. - * proc_pty_close_keepalive is a cheap no-op for non-pty fds and - * prevents the keepalive slave from leaking past a /dev/ptmx close when - * no per-type cleanup is registered. + * host_fd that the slow path drops must be drained here too. A no-op + * for anything that is not a pty, and a pty slave is an ordinary + * FD_REGULAR slot, so every guest close of one lands here. */ - proc_pty_close_keepalive(host_fd); - - /* A pty slave is an ordinary FD_REGULAR slot, so every guest close of - * one lands here rather than in fd_cleanup_entry. Without this the - * per-master slave count never falls back to zero and the master never - * reports its hangup. - */ - proc_pty_slave_fd_closed(host_fd); + proc_pty_forget_host_fd(host_fd); chown_overlay_clear_closed_unlinked_fd(host_fd); if (close(host_fd) < 0) return linux_errno(); @@ -1002,8 +1009,7 @@ static int duplicate_guest_fd(int src_fd, * is about to be closed on the books, and the master would never see * its last slave go. */ - proc_pty_close_keepalive(new_host_fd); - proc_pty_slave_fd_closed(new_host_fd); + proc_pty_forget_host_fd(new_host_fd); close_keep_errno(new_host_fd); return -1; } @@ -1833,6 +1839,15 @@ int64_t sys_fchdir(int fd) char proc_virt[64]; const char *proc_virtual = proc_virtual_dir_path( fd_table[fd].proc_path, proc_virt, sizeof(proc_virt)); + + /* /dev/pts is not a /proc path, so proc_virtual_dir_path does not name it, + * but it is virtual for the same reason: the host directory behind it holds + * placeholder files, not the slaves. Publishing the guest spelling is what + * lets a relative open resolved against this cwd re-derive /dev/pts/N and + * reach the intercept, exactly as a directory fd does. + */ + if (!proc_virtual && !strcmp(fd_table[fd].proc_path, "/dev/pts")) + proc_virtual = "/dev/pts"; if (fchdir(host_ref.fd) < 0) { host_fd_ref_close(&host_ref); return linux_errno(); diff --git a/src/syscall/path.c b/src/syscall/path.c index a3673bfe..abe7f56e 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -866,8 +866,13 @@ static int resolve_proc_cwd_path(const char *path, char *out, size_t outsz) if (proc_acquire_cwd_view(&view) < 0) return 0; + /* /dev/pts joins /proc here: both are served from host directories whose + * contents are not what the guest names, so a relative path measured + * against one has to be rebuilt as a guest path and re-offered to the + * intercepts. The component walk below is base-agnostic. + */ int rc = 0; - if (!strncmp(view.path, "/proc", 5)) { + if (!strncmp(view.path, "/proc", 5) || !strncmp(view.path, "/dev/pts", 8)) { size_t marks[PROC_PATH_COMPONENTS_MAX]; size_t depth; if (proc_seed_absolute_path(view.path, out, outsz, marks, diff --git a/tests/test-pty.c b/tests/test-pty.c index 8834aa25..33a0e8cb 100644 --- a/tests/test-pty.c +++ b/tests/test-pty.c @@ -47,8 +47,8 @@ #include "test-harness.h" -/* Linux spells the ordinary-data packet type this way; the - * value is 0 and matches macOS. +/* Linux spells the ordinary-data packet type this way; the value is 0 and + * matches macOS. */ #ifndef TIOCPKT_DATA #define TIOCPKT_DATA 0 @@ -382,6 +382,7 @@ int main(void) int child_ok = (n == 1) && (status == 'Y') && WIFEXITED(wstatus) && WEXITSTATUS(wstatus) == 0; EXPECT_TRUE(child_ok, "child TIOCSWINSZ on master failed"); + /* Parent should still see the child's update because the slave * keepalive in the parent is still alive. */ @@ -436,9 +437,18 @@ int main(void) } else { char spawn_pts[32]; snprintf(spawn_pts, sizeof(spawn_pts), "/dev/pts/%u", spawn_ptyno); - int spawn_pipe[2]; - if (pipe(spawn_pipe) != 0) { + int spawn_pipe[2] = {-1, -1}; + int stale_pipe[2] = {-1, -1}; + if (pipe(spawn_pipe) != 0 || pipe(stale_pipe) != 0) { FAIL("pipe for spawn scenario"); + if (spawn_pipe[0] >= 0) + close(spawn_pipe[0]); + if (spawn_pipe[1] >= 0) + close(spawn_pipe[1]); + if (stale_pipe[0] >= 0) + close(stale_pipe[0]); + if (stale_pipe[1] >= 0) + close(stale_pipe[1]); close(spawn_master); } else { pid_t spawn_pid = fork(); @@ -446,12 +456,15 @@ int main(void) FAIL("fork for spawn scenario"); close(spawn_pipe[0]); close(spawn_pipe[1]); + close(stale_pipe[0]); + close(stale_pipe[1]); close(spawn_master); } else if (spawn_pid == 0) { /* Child: foot's slave_exec sequence -- close the master, * then open(pts_name) for the controlling terminal. */ close(spawn_pipe[0]); + close(stale_pipe[1]); if (setsid() < 0) _exit(11); close(spawn_master); @@ -462,18 +475,23 @@ int main(void) (void) !write(slave_fd, "ok\n", 3); close(slave_fd); } + char go; + if (read(stale_pipe[0], &go, 1) == 1) { + int stale_fd = open(spawn_pts, O_RDWR); + char stale_status = (stale_fd >= 0) ? 'Y' : 'N'; + (void) !write(spawn_pipe[1], &stale_status, 1); + if (stale_fd >= 0) + close(stale_fd); + } + close(stale_pipe[0]); close(spawn_pipe[1]); _exit(slave_fd >= 0 ? 0 : 12); } else { close(spawn_pipe[1]); + close(stale_pipe[0]); char status = '?'; ssize_t n = read(spawn_pipe[0], &status, 1); - close(spawn_pipe[0]); - int wstatus = 0; - waitpid(spawn_pid, &wstatus, 0); - int spawn_ok = (n == 1) && (status == 'Y') && - WIFEXITED(wstatus) && - WEXITSTATUS(wstatus) == 0; + int spawn_ok = (n == 1) && (status == 'Y'); EXPECT_TRUE(spawn_ok, "child open(/dev/pts/N) after close(master)"); if (spawn_ok) { @@ -487,20 +505,18 @@ int main(void) } } close(spawn_master); + (void) !write(stale_pipe[1], "x", 1); + close(stale_pipe[1]); TEST("stale /dev/pts/N expires after master teardown"); - int stale_fd = open(spawn_pts, O_RDWR); - /* Both ENOENT (devfs node gone) and ENXIO (devfs node - * lingers but the pty pair has been torn down) are valid - * macOS responses depending on kernel version. The - * invariant the test guards is "the stale cached path does - * not silently hand back an unrelated tty"; any open - * failure satisfies that. - */ - int stale_ok = - stale_fd < 0 && (errno == ENOENT || errno == ENXIO); - if (stale_fd >= 0) - close(stale_fd); + char stale_status = '?'; + ssize_t stale_n = read(spawn_pipe[0], &stale_status, 1); + close(spawn_pipe[0]); + int wstatus = 0; + waitpid(spawn_pid, &wstatus, 0); + int stale_ok = stale_n == 1 && stale_status == 'N' && + WIFEXITED(wstatus) && + WEXITSTATUS(wstatus) == 0; EXPECT_TRUE(stale_ok, "stale /dev/pts/N stayed openable"); } } @@ -591,6 +607,7 @@ int main(void) char recv_pts_path[32]; snprintf(recv_pts_path, sizeof(recv_pts_path), "/dev/pts/%u", recv_ptyno); + /* TIOCPKT has to work on a master the receiver never * opened itself, the shape libvte sees when a terminal * is handed a pty from elsewhere. @@ -654,11 +671,11 @@ int main(void) TEST("packet-mode read carries a status byte"); FAIL("open slave"); } else { - /* Raw mode so the slave does not echo the payload back - * at the master and confuse the packet stream. A failure - * here is a setup failure rather than a verdict on packet - * mode, so it gets its own message below: reporting it as - * a bad read would blame the feature under test. + /* Raw mode so the slave does not echo the payload back at + * the master and confuse the packet stream. A failure here + * is a setup failure rather than a verdict on packet mode, + * so it gets its own message below: reporting it as a bad + * read would blame the feature under test. */ struct termios tio; bool raw_ok = tcgetattr(pkt_slave, &tio) == 0; @@ -765,8 +782,8 @@ int main(void) FAIL("open slave"); } else { /* Queued output must survive: Linux hands over what the slave - * wrote before reporting the hangup, so a shell's parting - * words are not swallowed. + * wrote before reporting the hangup, so a shell's parting words + * are not swallowed. */ static const char bye[] = "bye"; ssize_t put = write(hup_slave, bye, sizeof(bye) - 1); @@ -844,10 +861,10 @@ int main(void) EXPECT_TRUE(er > 0 && ev.data.u64 == 0x5eed, "hangup event lost its user data"); - /* An already-pending hangup must not be held until the - * caller's deadline: the host never makes the fd ready, so a - * finite wait that only checks after kevent returns would - * block for the full timeout before reporting. + /* An already-pending hangup must not be held until the caller's + * deadline: the host never makes the fd ready, so a finite wait + * that only checks after kevent returns would block for the + * full timeout before reporting. */ struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); @@ -1076,10 +1093,10 @@ int main(void) /* What a terminal actually does to hand a shell its tty: dup2 the slave * onto stdin/stdout/stderr, then close the original fd. Only the open() was - * ever counted, so those three live references were invisible and the - * close of the original drove the count to zero -- the master reported a - * hangup with the shell still running. Every other case here opens a slave - * and keeps that same fd, which is why none of them caught it. + * ever counted, so those three live references were invisible and the close + * of the original drove the count to zero -- the master reported a hangup + * with the shell still running. Every other case here opens a slave and + * keeps that same fd, which is why none of them caught it. */ { int dp_master = open("/dev/ptmx", O_RDWR | O_NOCTTY); @@ -1173,6 +1190,196 @@ int main(void) } } + /* Slave accounting belongs to the pty, not to whichever master fd happens + * to hold it. Aliased masters each get their own keepalive row, so closing + * one used to hand the whole slave count back and report a hangup with the + * slave still open. + */ + { + int am = open("/dev/ptmx", O_RDWR | O_NONBLOCK | O_NOCTTY); + unsigned int an = 0; + int aunlock = 0; + if (am >= 0 && ioctl(am, TIOCGPTN, &an) == 0 && + ioctl(am, TIOCSPTLCK, &aunlock) == 0) { + char apath[64]; + snprintf(apath, sizeof(apath), "/dev/pts/%u", an); + int aslave = open(apath, O_RDWR | O_NOCTTY); + int aalias = dup(am); + if (aslave >= 0 && aalias >= 0) { + close(am); + + TEST("closing one master alias does not hang up a live slave"); + struct pollfd apf = {.fd = aalias, .events = POLLIN}; + poll(&apf, 1, 0); + EXPECT_TRUE((apf.revents & POLLHUP) == 0, + "alias reported POLLHUP with the slave still open"); + + TEST("reading that alias gives EAGAIN, not the hangup EIO"); + char adrain[8]; + errno = 0; + ssize_t ard = read(aalias, adrain, sizeof(adrain)); + int aerr = errno; + EXPECT_TRUE(ard < 0 && aerr != EIO, + "alias read reported the hangup as EIO"); + + TEST("the hangup arrives once the slave really closes"); + close(aslave); + aslave = -1; + struct pollfd apf2 = {.fd = aalias, .events = POLLIN}; + poll(&apf2, 1, 500); + EXPECT_TRUE((apf2.revents & POLLHUP) != 0, + "no POLLHUP after the last slave closed"); + am = aalias; + aalias = -1; + } + if (aslave >= 0) + close(aslave); + if (aalias >= 0) + close(aalias); + } + if (am >= 0) + close(am); + } + + /* A slave open that fails after the intercept ran must not leave the slave + * counted, or the master can never report its hangup again. + */ + { + int lm = open("/dev/ptmx", O_RDWR | O_NONBLOCK | O_NOCTTY); + unsigned int ln = 0; + int lunlock = 0; + if (lm >= 0 && ioctl(lm, TIOCGPTN, &ln) == 0 && + ioctl(lm, TIOCSPTLCK, &lunlock) == 0) { + char lpath[64]; + snprintf(lpath, sizeof(lpath), "/dev/pts/%u", ln); + int lslave = open(lpath, O_RDWR | O_NOCTTY); + + TEST("a slave open rejected by O_DIRECTORY reports ENOTDIR"); + int lbad = open(lpath, O_RDWR | O_DIRECTORY); + int lbad_errno = errno; + EXPECT_TRUE(lbad < 0 && lbad_errno == ENOTDIR, + "O_DIRECTORY on a pty slave did not report ENOTDIR"); + if (lbad >= 0) + close(lbad); + + TEST("the rejected open left no phantom slave behind"); + if (lslave >= 0) + close(lslave); + struct pollfd lpf = {.fd = lm, .events = POLLIN}; + poll(&lpf, 1, 500); + EXPECT_TRUE((lpf.revents & POLLHUP) != 0, + "master never hung up after its only slave closed"); + } + if (lm >= 0) + close(lm); + } + + /* /dev/pts is served from a staging directory of placeholder files, so a + * relative call measured against that directory fd has to re-derive the + * guest path rather than reach the placeholder. + */ + { + int dm = open("/dev/ptmx", O_RDWR | O_NOCTTY); + unsigned int dn = 0; + int dunlock = 0; + if (dm >= 0 && ioctl(dm, TIOCGPTN, &dn) == 0 && + ioctl(dm, TIOCSPTLCK, &dunlock) == 0) { + int dfd = open("/dev/pts", O_RDONLY | O_DIRECTORY); + char dname[16]; + snprintf(dname, sizeof(dname), "%u", dn); + + TEST("fstatat through the /dev/pts fd reports a character device"); + struct stat dst; + int drc = dfd >= 0 ? fstatat(dfd, dname, &dst, 0) : -1; + EXPECT_TRUE(drc == 0 && S_ISCHR(dst.st_mode), + "relative stat saw the staging placeholder"); + + TEST("openat through the /dev/pts fd yields the slave itself"); + int ds = dfd >= 0 ? openat(dfd, dname, O_RDWR | O_NOCTTY) : -1; + EXPECT_TRUE(ds >= 0 && isatty(ds), + "relative open did not give a tty"); + if (ds >= 0) + close(ds); + + /* A cwd on /dev/pts has to re-derive the same way a directory fd + * does, or the placeholder is what a bare name reaches. + */ + TEST("a relative open with /dev/pts as cwd reaches the slave"); + char cwd_save[4096]; + const char *saved = getcwd(cwd_save, sizeof(cwd_save)); + if (!saved) { + FAIL( + "getcwd failed, refusing to move the cwd without a way " + "back"); + } else if (dfd < 0 || fchdir(dfd) != 0) { + FAIL("fchdir onto /dev/pts failed"); + } else { + int cs = open(dname, O_RDWR | O_NOCTTY); + EXPECT_TRUE(cs >= 0 && isatty(cs), + "relative open under a /dev/pts cwd missed the " + "slave"); + if (cs >= 0) + close(cs); + + /* Every later test resolves relative paths, and the forked + * child inherits this, so a restore that quietly failed would + * run the rest of the suite somewhere else. + */ + TEST("the cwd is restored after the /dev/pts excursion"); + EXPECT_TRUE(chdir(cwd_save) == 0, + "could not return to the original cwd"); + } + if (dfd >= 0) + close(dfd); + } + if (dm >= 0) + close(dm); + } + + /* A forked child keeps its inherited /dev/pts/N mapping usable while the + * shared pty lives, and registering that mapping must not disturb fds the + * child already holds. + */ + { + int fm = open("/dev/ptmx", O_RDWR | O_NOCTTY); + unsigned int fn = 0; + int funlock = 0; + if (fm >= 0 && ioctl(fm, TIOCGPTN, &fn) == 0 && + ioctl(fm, TIOCSPTLCK, &funlock) == 0) { + char fpath[64]; + snprintf(fpath, sizeof(fpath), "/dev/pts/%u", fn); + int fkeep = open(fpath, O_RDWR | O_NOCTTY); + fflush(stdout); + pid_t fpid = fork(); + if (fpid == 0) { + /* An unrelated descriptor the restore path must not disturb. */ + int victim = open("/dev/null", O_RDONLY); + close(fm); + int first = open(fpath, O_RDWR | O_NOCTTY); + int second = open(fpath, O_RDWR | O_NOCTTY); + struct stat cst; + int strc = stat(fpath, &cst); + char vb[1]; + int vrc = victim >= 0 ? (int) read(victim, vb, 1) : -1; + _exit(first >= 0 && second >= 0 && strc == 0 && vrc >= 0 ? 0 + : 1); + } + TEST("a forked child can reopen and stat its inherited slave"); + if (fpid < 0) { + FAIL("fork failed"); + } else { + int fst = 0; + waitpid(fpid, &fst, 0); + EXPECT_TRUE(WIFEXITED(fst) && WEXITSTATUS(fst) == 0, + "child lost its slave mapping or an unrelated fd"); + } + if (fkeep >= 0) + close(fkeep); + } + if (fm >= 0) + close(fm); + } + SUMMARY("test-pty"); return fails > 0 ? 1 : 0; } From 82bb7a9fcc3034726be87a6bf01c8c5781e3e57b Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Thu, 13 Aug 2026 11:34:43 +0800 Subject: [PATCH 06/15] Stop the proof and mutation gates waiting make verify ran its sixteen targets one after another although each is a separate frama-c process writing its own log and sharing nothing. Only invocations that remembered -j got the parallelism. It recurses with -j itself now, and adds one only when the caller brought none: a forced -j in a submake makes it drop the inherited jobserver and start that many processes whatever the outer limit was. 46.6s to 14.4s here. Make records no -j for -j1, so that spelling still parallelizes under the 3.81 macOS ships; VERIFY_JOBS=1 is the way to ask for serial under either. Each mutation re-proved every function in its target when WP proves each function against its callees' contracts rather than their bodies, so editing a body can only move that one function's goals. Mutations prove just the function they name. Editing a contract does move its callers' goals, and no entry names the caller, so those stay at full scope; ACSL living in comments is what makes them detectable. The narrowing is arranged so it cannot change a verdict rather than merely being argued not to. A mutation the narrow run fails to catch is re-run over the whole target before it may read as MISSED, so the fast path carries the common case and a wrong scope costs time instead of coverage. Misdirecting a mutation's scope on purpose still reports it caught. 3m36s to 2m16s, all 88 still caught. --- mk/analysis.mk | 35 ++++++++++++++++++-- scripts/analysis-mk.py | 3 +- scripts/check-char-signedness.py | 4 ++- scripts/check-mutants.py | 57 ++++++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/mk/analysis.mk b/mk/analysis.mk index 6f1bbdb8..5b85ee4b 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -97,6 +97,15 @@ FRAMAC ?= frama-c FRAMAC_DATA_MODEL ?= gcc_x86_64 FRAMAC_TIMEOUT ?= 30 +# Provers, tried in order until one discharges the goal. Both are listed +# because each closes goals the other does not, so dropping either loses +# proofs. A run that only has to establish that some goal FAILS does not need +# the second opinion, and pays the full timeout twice per unprovable goal +# without it. Nothing in the tree narrows it: the mutation gate was tried on +# one prover and is unsound that way, because its baseline only shows the +# ORIGINAL goals discharge with the kept prover. +FRAMAC_PROVERS ?= alt-ergo,z3 + # The analyzer parses against Frama-C's own modeled libc headers, never the # host's. -print-share-path runs at recipe time rather than through $(shell) so # a make invocation with no frama-c installed does not pay for it. @@ -364,7 +373,7 @@ $(VERIFY_RULES): | $(BUILD_DIR) -cpp-extra-args="$(FRAMAC_CPP_ARGS)" \ $(SRC) -wp -wp-rte -wp-model $(MODEL) \ -wp-fct $(FCT_ARG) \ - -wp-prover alt-ergo,z3 -wp-timeout $(FRAMAC_TIMEOUT) \ + -wp-prover $(FRAMAC_PROVERS) -wp-timeout $(FRAMAC_TIMEOUT) \ > $(BUILD_DIR)/verify-$(NAME).log 2>&1; \ python3 scripts/check-wp-result.py --status $$? \ --log $(BUILD_DIR)/verify-$(NAME).log --min-goals $(MIN_GOALS) \ @@ -412,8 +421,30 @@ check-char-signedness: @echo " CHARSIGN proof sources under both char signedness settings" $(Q)python3 scripts/check-char-signedness.py --cc '$(CC)' +# Proof jobs. Each verify-* target is one frama-c process writing its own log +# and sharing nothing with the others, so the only thing serializing them was +# make itself. +VERIFY_JOBS ?= $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) + ## Run every Frama-C proof -verify: $(VERIFY_RULES) +# +# Recursive rather than a prerequisite list, so a plain "make verify" gets the +# parallelism instead of only the invocations that remember -j. The leading '+' +# is what keeps this expanding under -n: make looks for a literal $(MAKE) in the +# unexpanded recipe line to decide that, and marks the line as still-runs. +# +# The -j is added only when the caller did not bring one. A forced -j in a +# submake makes it drop the inherited jobserver and start that many processes +# regardless of the outer limit, so "make -j2 verify" would run VERIFY_JOBS of +# them. Passing nothing lets the jobserver do its job. +# +# "make -j1 verify" is serial under GNU make 4.x, which keeps -j1 in MAKEFLAGS +# for the filter below to find. Apple's /usr/bin/make is 3.81 and records +# nothing for -j1, so there it reads as a plain invocation and parallelizes; +# VERIFY_JOBS=1 asks for serial in a way both understand. +verify: + +@$(MAKE) --no-print-directory \ + $(if $(filter -j%,$(MAKEFLAGS)),,-j$(VERIFY_JOBS)) $(VERIFY_RULES) ## Rebuild with the proved/gva.h precondition checks live, then run the suite # diff --git a/scripts/analysis-mk.py b/scripts/analysis-mk.py index 79157444..da04c67b 100644 --- a/scripts/analysis-mk.py +++ b/scripts/analysis-mk.py @@ -34,8 +34,7 @@ def target_sources(): """ return { m.group(1).lower(): m.group(2) - for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text(), - re.M) + for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text(), re.M) } diff --git a/scripts/check-char-signedness.py b/scripts/check-char-signedness.py index 4544cbf2..fd3cdcaa 100755 --- a/scripts/check-char-signedness.py +++ b/scripts/check-char-signedness.py @@ -62,7 +62,9 @@ def proof_sources(): srcs = { m.group(1).lower(): m.group(2).strip() - for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text, re.MULTILINE) + for m in re.finditer( + r"^VERIFY_([A-Z0-9_]+)_SRC\s*:=\s*(\S+)", text, re.MULTILINE + ) } out = {} for m in re.finditer(r"^VERIFY_([A-Z0-9_]+)_FCTS\s*:=\s*(.*)$", text, re.MULTILINE): diff --git a/scripts/check-mutants.py b/scripts/check-mutants.py index 4cbf027f..670e8482 100755 --- a/scripts/check-mutants.py +++ b/scripts/check-mutants.py @@ -1004,11 +1004,54 @@ def proved_functions(): ) -def run_target(target, source_copy, name): +# Mutations run against the same prover set as the proofs, deliberately. +# +# Narrowing to one prover was tried and is unsound for the reason the timeout +# shortcut is: the baseline only shows that the ORIGINAL goals discharge with +# that prover, and a mutation does not fail a goal, it replaces it. The +# replacement can be true but awkward, provable by the dropped prover and not +# by the kept one, and that scores as caught while the contract rejected +# nothing. It is the same trap in a different variable, and the baseline cannot +# see it either. + + +# ACSL lives in comments, so a mutation that edits a contract cannot be told +# from one that edits a body by looking at the function name. These markers can. +ACSL_MARKERS = ("ensures", "requires", "assigns", "@") + + +def mutation_scope(function, old, new): + """The single function worth proving for this mutation, or None for all. + + WP proves each function against its callees' CONTRACTS, never their bodies, + so editing a body can only move that one function's goals. Editing a + contract moves its callers' goals too, and the caller is not named anywhere + in the entry, so those run at full scope. + """ + if any(m in old or m in new for m in ACSL_MARKERS): + return None + return function + + +def run_target(target, source_copy, name, fct=None): """Run verify- against @source_copy. Returns (ok, output).""" var = f"VERIFY_{target.upper()}_SRC" + args = [ + "make", + f"verify-{target}", + f"{var}={source_copy}", + f"NAME={name}", + ] + + # Narrowing the proof set also drops the goal count below the target's + # floor, so the floor has to come down with it. The unrestricted baseline + # keeps the real floor, and any run that fails to catch its mutation is + # repeated at full scope before the verdict stands, so nothing rests on the + # narrowed run alone. + if fct: + args += [f"FCT_ARG={fct}", "MIN_GOALS=1"] proc = subprocess.run( - ["make", f"verify-{target}", f"{var}={source_copy}", f"NAME={name}"], + args, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -1050,7 +1093,15 @@ def run_mutation(idx, mutation): # NAME picks the log path, so each mutation gets its own. Concurrent # mutations of one target would otherwise clobber a shared log, and a # clobbered log makes the target FAIL, which reads as "caught". - ok, out = run_target(target, copy, f"mutants/{target}-mut{idx:02d}") + # Prove only what this mutation can have moved. A caught mutation is the + # common case and is where the time goes, so the narrow run carries it; a + # run that does NOT catch is repeated over the whole target before it is + # allowed to read as MISSED, which is what keeps the narrowing from turning + # a real gap into a pass. + scope = mutation_scope(_function, old, new) + ok, out = run_target(target, copy, f"mutants/{target}-mut{idx:02d}", scope) + if ok and scope: + ok, out = run_target(target, copy, f"mutants/{target}-mut{idx:02d}") if ok: return "MISSED", "the target still passed" From 702403343fc85a57e90c4b65b95024ae2ca5c879 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:29:14 +0800 Subject: [PATCH 07/15] Declare the HVF and Darwin names analyzer lacks Frama-C has no Apple SDK, so every source reaching guest.h or thread.h stopped at a missing Hypervisor header before parsing began, and its portable libc omits both the type-generic __atomic_*_n builtins and a few Darwin constants the tree calls. An absent builtin is worse than an absent header: the call parses as an implicit declaration whose argument types are inferred per translation unit, so each file loads on its own and the project refuses the moment two of them infer different widths for the same name. frama-c-stubs declares what is missing and nothing else. gcc-atomics.h pulls in Frama-C's own __fc_gcc_builtins.h for the builtins it does model and stdatomic.h for the _Atomic qualifier, where Frama-C states the qualifier is ignored, so that concession is the analyzer's rather than one invented here; it adds only the generics with no entry at all. Values in the stubs are the real ones where the tree could observe them. Outside src/ deliberately: a compile resolves headers through -Isrc and would find a stub shadowing the SDK header the binary must link against. Only FRAMAC_STUB_DIR reaches them. A name none of them declares fails with "Cannot resolve variable", which is how the next one gets found. --- frama-c-stubs/Hypervisor/Hypervisor.h | 234 ++++++++++++++++++++++++++ frama-c-stubs/gcc-atomics.h | 124 ++++++++++++++ frama-c-stubs/macos-libc.h | 45 +++++ mk/analysis.mk | 34 +++- 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 frama-c-stubs/Hypervisor/Hypervisor.h create mode 100644 frama-c-stubs/gcc-atomics.h create mode 100644 frama-c-stubs/macos-libc.h diff --git a/frama-c-stubs/Hypervisor/Hypervisor.h b/frama-c-stubs/Hypervisor/Hypervisor.h new file mode 100644 index 00000000..81847d82 --- /dev/null +++ b/frama-c-stubs/Hypervisor/Hypervisor.h @@ -0,0 +1,234 @@ +/* + * Hypervisor.framework declarations, for the analyzer only + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Frama-C preprocesses with its own modeled libc and has no Apple SDK, so any + * source reaching src/core/guest.h or src/runtime/thread.h stopped at + * "Hypervisor/Hypervisor.h file not found" before it could be parsed at all. + * That is not a libc modeling gap and does not need one: the two headers use a + * small, closed set of HVF names, and declaring them is enough to let the + * analyzer read the rest of the file. + * + * Deliberately outside src/. A compile resolves headers through -Isrc, so a + * stub living there would sit on the real build's include path and could shadow + * the SDK header the binary must link against. Up here nothing but + * FRAMAC_STUB_DIR in mk/analysis.mk can reach it. + * + * This is reached only through mk/analysis.mk, never by a compile. Nothing + * proved reads any constant defined here, so the values matter only in that + * they must not collide: HV_REG_X0 + n is how src/hvutil.h names a register, + * which needs the X registers consecutive and in order, and the rest are + * distinct placeholders. A wrong value here cannot weaken a proof, but it can + * make a walked switch look degenerate, so keep them apart. + * + * The set is what the parsing sources actually reference. Widening the proof + * surface to a source that names one more will fail with "Cannot resolve + * variable", which is the intended way to find out that it belongs here. + */ + +#pragma once + +#include +#include + +typedef int hv_return_t; + +#define HV_SUCCESS 0 +#define HV_BAD_ARGUMENT 0xfae94001 + +typedef uint64_t hv_vcpu_t; +typedef uint32_t hv_reg_t; + +/* X0 through X30 consecutive and in order: src/hvutil.h forms HV_REG_X0 + n. + * The rest only have to be distinct from those and from each other. + */ +enum { + HV_REG_X0 = 0, + HV_REG_X1 = 1, + HV_REG_X2 = 2, + HV_REG_X3 = 3, + HV_REG_X4 = 4, + HV_REG_X5 = 5, + HV_REG_X6 = 6, + HV_REG_X7 = 7, + HV_REG_X8 = 8, + HV_REG_X9 = 9, + HV_REG_X10 = 10, + HV_REG_X11 = 11, + HV_REG_X12 = 12, + HV_REG_X13 = 13, + HV_REG_X14 = 14, + HV_REG_X15 = 15, + HV_REG_X16 = 16, + HV_REG_X17 = 17, + HV_REG_X18 = 18, + HV_REG_X19 = 19, + HV_REG_X20 = 20, + HV_REG_X21 = 21, + HV_REG_X22 = 22, + HV_REG_X23 = 23, + HV_REG_X24 = 24, + HV_REG_X25 = 25, + HV_REG_X26 = 26, + HV_REG_X27 = 27, + HV_REG_X28 = 28, + HV_REG_X29 = 29, + HV_REG_X30 = 30, + HV_REG_PC = 32, + HV_REG_CPSR = 33, + HV_REG_FPCR = 34, + HV_REG_FPSR = 35, +}; + +typedef uint32_t hv_sys_reg_t; +typedef uint32_t hv_simd_fp_reg_t; + +#define HV_SIMD_FP_REG_Q0 0 + +/* The real type is a 16-byte vector. Frama-C rejects the vector_size + * attribute, and no proved function reads a lane, so a struct of the same + * width and alignment stands in. + */ +typedef struct { + unsigned char v[16]; +} hv_simd_fp_uchar16_t; + +typedef uint64_t hv_ipa_t; +typedef uint32_t hv_memory_flags_t; + +#define HV_MEMORY_READ 1 +#define HV_MEMORY_WRITE 2 +#define HV_MEMORY_EXEC 4 + +typedef uint32_t hv_exit_reason_t; + +#define HV_EXIT_REASON_CANCELED 1 + +typedef struct { + uint64_t syndrome; + uint64_t virtual_address; + uint64_t physical_address; +} hv_vcpu_exit_exception_t; + +typedef struct { + hv_exit_reason_t reason; + hv_vcpu_exit_exception_t exception; +} hv_vcpu_exit_t; + +hv_return_t hv_vcpu_destroy(hv_vcpu_t vcpu); +hv_return_t hv_vcpu_run(hv_vcpu_t vcpu); +hv_return_t hv_vcpus_exit(hv_vcpu_t *vcpus, unsigned int vcpu_count); +hv_return_t hv_vcpu_get_reg(hv_vcpu_t vcpu, hv_reg_t reg, uint64_t *value); +hv_return_t hv_vcpu_set_reg(hv_vcpu_t vcpu, hv_reg_t reg, uint64_t value); +hv_return_t hv_vcpu_get_sys_reg(hv_vcpu_t vcpu, + hv_sys_reg_t reg, + uint64_t *value); +hv_return_t hv_vcpu_set_sys_reg(hv_vcpu_t vcpu, + hv_sys_reg_t reg, + uint64_t value); +hv_return_t hv_vcpu_get_simd_fp_reg(hv_vcpu_t vcpu, + hv_simd_fp_reg_t reg, + hv_simd_fp_uchar16_t *value); +hv_return_t hv_vcpu_set_simd_fp_reg(hv_vcpu_t vcpu, + hv_simd_fp_reg_t reg, + hv_simd_fp_uchar16_t value); +hv_return_t hv_vcpu_set_trap_debug_exceptions(hv_vcpu_t vcpu, int value); +hv_return_t hv_vm_map(void *addr, + hv_ipa_t ipa, + size_t size, + hv_memory_flags_t flags); +hv_return_t hv_vm_unmap(hv_ipa_t ipa, size_t size); +hv_return_t hv_vm_destroy(void); + +/* System registers. Nothing in the tree does arithmetic on these, unlike the + * GPRs above; they appear as array initialisers and switch labels, so distinct + * values are the whole requirement. Generated from every HV_SYS_REG_ name the + * tree references, so the set matches the code rather than a hand-kept list. + */ +enum { + HV_SYS_REG_ACTLR_EL1 = 4096, + HV_SYS_REG_CNTKCTL_EL1 = 4097, + HV_SYS_REG_CONTEXTIDR_EL1 = 4098, + HV_SYS_REG_CPACR_EL1 = 4099, + HV_SYS_REG_DBGBCR0_EL1 = 4100, + HV_SYS_REG_DBGBCR10_EL1 = 4101, + HV_SYS_REG_DBGBCR11_EL1 = 4102, + HV_SYS_REG_DBGBCR12_EL1 = 4103, + HV_SYS_REG_DBGBCR13_EL1 = 4104, + HV_SYS_REG_DBGBCR14_EL1 = 4105, + HV_SYS_REG_DBGBCR15_EL1 = 4106, + HV_SYS_REG_DBGBCR1_EL1 = 4107, + HV_SYS_REG_DBGBCR2_EL1 = 4108, + HV_SYS_REG_DBGBCR3_EL1 = 4109, + HV_SYS_REG_DBGBCR4_EL1 = 4110, + HV_SYS_REG_DBGBCR5_EL1 = 4111, + HV_SYS_REG_DBGBCR6_EL1 = 4112, + HV_SYS_REG_DBGBCR7_EL1 = 4113, + HV_SYS_REG_DBGBCR8_EL1 = 4114, + HV_SYS_REG_DBGBCR9_EL1 = 4115, + HV_SYS_REG_DBGBVR0_EL1 = 4116, + HV_SYS_REG_DBGBVR10_EL1 = 4117, + HV_SYS_REG_DBGBVR11_EL1 = 4118, + HV_SYS_REG_DBGBVR12_EL1 = 4119, + HV_SYS_REG_DBGBVR13_EL1 = 4120, + HV_SYS_REG_DBGBVR14_EL1 = 4121, + HV_SYS_REG_DBGBVR15_EL1 = 4122, + HV_SYS_REG_DBGBVR1_EL1 = 4123, + HV_SYS_REG_DBGBVR2_EL1 = 4124, + HV_SYS_REG_DBGBVR3_EL1 = 4125, + HV_SYS_REG_DBGBVR4_EL1 = 4126, + HV_SYS_REG_DBGBVR5_EL1 = 4127, + HV_SYS_REG_DBGBVR6_EL1 = 4128, + HV_SYS_REG_DBGBVR7_EL1 = 4129, + HV_SYS_REG_DBGBVR8_EL1 = 4130, + HV_SYS_REG_DBGBVR9_EL1 = 4131, + HV_SYS_REG_DBGWCR0_EL1 = 4132, + HV_SYS_REG_DBGWCR10_EL1 = 4133, + HV_SYS_REG_DBGWCR11_EL1 = 4134, + HV_SYS_REG_DBGWCR12_EL1 = 4135, + HV_SYS_REG_DBGWCR13_EL1 = 4136, + HV_SYS_REG_DBGWCR14_EL1 = 4137, + HV_SYS_REG_DBGWCR15_EL1 = 4138, + HV_SYS_REG_DBGWCR1_EL1 = 4139, + HV_SYS_REG_DBGWCR2_EL1 = 4140, + HV_SYS_REG_DBGWCR3_EL1 = 4141, + HV_SYS_REG_DBGWCR4_EL1 = 4142, + HV_SYS_REG_DBGWCR5_EL1 = 4143, + HV_SYS_REG_DBGWCR6_EL1 = 4144, + HV_SYS_REG_DBGWCR7_EL1 = 4145, + HV_SYS_REG_DBGWCR8_EL1 = 4146, + HV_SYS_REG_DBGWCR9_EL1 = 4147, + HV_SYS_REG_DBGWVR0_EL1 = 4148, + HV_SYS_REG_DBGWVR10_EL1 = 4149, + HV_SYS_REG_DBGWVR11_EL1 = 4150, + HV_SYS_REG_DBGWVR12_EL1 = 4151, + HV_SYS_REG_DBGWVR13_EL1 = 4152, + HV_SYS_REG_DBGWVR14_EL1 = 4153, + HV_SYS_REG_DBGWVR15_EL1 = 4154, + HV_SYS_REG_DBGWVR1_EL1 = 4155, + HV_SYS_REG_DBGWVR2_EL1 = 4156, + HV_SYS_REG_DBGWVR3_EL1 = 4157, + HV_SYS_REG_DBGWVR4_EL1 = 4158, + HV_SYS_REG_DBGWVR5_EL1 = 4159, + HV_SYS_REG_DBGWVR6_EL1 = 4160, + HV_SYS_REG_DBGWVR7_EL1 = 4161, + HV_SYS_REG_DBGWVR8_EL1 = 4162, + HV_SYS_REG_DBGWVR9_EL1 = 4163, + HV_SYS_REG_ELR_EL1 = 4164, + HV_SYS_REG_ESR_EL1 = 4165, + HV_SYS_REG_FAR_EL1 = 4166, + HV_SYS_REG_MAIR_EL1 = 4167, + HV_SYS_REG_MDSCR_EL1 = 4168, + HV_SYS_REG_SCTLR_EL1 = 4169, + HV_SYS_REG_SPSR_EL1 = 4170, + HV_SYS_REG_SP_EL0 = 4171, + HV_SYS_REG_SP_EL1 = 4172, + HV_SYS_REG_TCR_EL1 = 4173, + HV_SYS_REG_TPIDR_EL0 = 4174, + HV_SYS_REG_TPIDR_EL1 = 4175, + HV_SYS_REG_TTBR0_EL1 = 4176, + HV_SYS_REG_TTBR1_EL1 = 4177, + HV_SYS_REG_VBAR_EL1 = 4178, +}; diff --git a/frama-c-stubs/gcc-atomics.h b/frama-c-stubs/gcc-atomics.h new file mode 100644 index 00000000..d839c1f9 --- /dev/null +++ b/frama-c-stubs/gcc-atomics.h @@ -0,0 +1,124 @@ +/* + * GCC atomic and overflow builtins, modeled for the analyzer only + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Frama-C's libc models the size-suffixed atomics (__atomic_fetch_or_4 and + * friends), __atomic_thread_fence, __sync_synchronize and the __builtin_ + * bit-counting and overflow-checking ones, in __fc_gcc_builtins.h. Nothing in + * the modeled libc includes that header, and it has no entry at all for the + * type-generic __atomic_*_n forms this tree actually calls. + * + * A source calling one of those gets an implicit declaration, whose argument + * types are inferred per translation unit, so two files that pass different + * widths conflict the moment they are loaded together: "Incompatible + * declaration for __atomic_store_n, different integer types, unsigned long and + * int". That is why a file could be proved on its own and still not join a + * whole-program load. + * + * So this pulls in what Frama-C already models and adds only the generics it + * does not. Redefining one it declares is an error rather than an override: a + * function-like macro over its prototype fails the preprocessor outright. + * + * Modeled as the single-threaded reads and writes they reduce to, which is the + * same trade -D_Atomic= already makes in mk/analysis.mk and carries the same + * limit: sound for the per-function runtime-error and bounds obligations these + * targets discharge, NOT sound for any analysis of concurrent behaviour. The + * memory order argument is evaluated and discarded, so a call that computes it + * keeps whatever side effect that had. + * + * Statement expressions rather than plain macros because fetch_ and exchange_ + * return the value from BEFORE the update, and a comma expression cannot hold + * it. Frama-C accepts ({ ... }) and __typeof__; that is checked by the proof + * targets that use this header, since a rejected construct fails the parse. + * + * Reached only through FRAMAC_CPP_ARGS, which force-includes it. A compile + * never sees this file and keeps the real builtins. + */ + +#pragma once + +/* Everything Frama-C already models, including contracts. Included here rather + * than left out because nothing in the modeled libc pulls it in, so without + * this __builtin_ctzll and __builtin_add_overflow are implicit declarations + * too. + */ +#include <__fc_gcc_builtins.h> + +/* Frama-C's own handling of the _Atomic qualifier, which its front end cannot + * parse. stdatomic.h carries "#define _Atomic" with the comment "_Atomic is + * currently ignored by Frama-C", so this is the analyzer's stated position on + * the keyword rather than a flag invented here. Taken from that header instead + * of restated as a -D, so the concession lives next to the atomics model that + * shares its reasoning, and so it moves when Frama-C's does. + * + * src/syscall/linux-wire.h qualifies one fd_entry_t field, and the tree does + * not include stdatomic.h anywhere, which is why the definition has to arrive + * ahead of the source rather than through a normal include. + */ +#include + +/* The order argument is (void)-cast rather than dropped, so a caller passing an + * expression with a side effect still gets it. + */ +#define __atomic_load_n(ptr, order) ((void) (order), *(ptr)) + +#define __atomic_store_n(ptr, val, order) \ + ((void) (order), (void) (*(ptr) = (val))) + +#define __atomic_load(ptr, ret, order) \ + ((void) (order), (void) (*(ret) = *(ptr))) + +#define __atomic_fetch_or(ptr, val, order) \ + ({ \ + (void) (order); \ + __typeof__(*(ptr)) __fc_old = *(ptr); \ + *(ptr) = (__typeof__(*(ptr))) (__fc_old | (val)); \ + __fc_old; \ + }) + +#define __atomic_fetch_and(ptr, val, order) \ + ({ \ + (void) (order); \ + __typeof__(*(ptr)) __fc_old = *(ptr); \ + *(ptr) = (__typeof__(*(ptr))) (__fc_old & (val)); \ + __fc_old; \ + }) + +#define __atomic_fetch_add(ptr, val, order) \ + ({ \ + (void) (order); \ + __typeof__(*(ptr)) __fc_old = *(ptr); \ + *(ptr) = (__typeof__(*(ptr))) (__fc_old + (val)); \ + __fc_old; \ + }) + +#define __atomic_exchange_n(ptr, val, order) \ + ({ \ + (void) (order); \ + __typeof__(*(ptr)) __fc_old = *(ptr); \ + *(ptr) = (val); \ + __fc_old; \ + }) + +/* Always succeeds when the comparison holds, which is the strong form. The weak + * flag is discarded: a spurious failure is a thread-visible behaviour, and + * nothing modeled here has threads. + */ +#define __atomic_compare_exchange_n(ptr, expected, desired, weak, succ, fail) \ + ({ \ + (void) (weak); \ + (void) (succ); \ + (void) (fail); \ + int __fc_ok = (*(ptr) == *(expected)); \ + if (__fc_ok) \ + *(ptr) = (desired); \ + else \ + *(expected) = *(ptr); \ + __fc_ok; \ + }) + +/* __atomic_thread_fence and __sync_synchronize are declared by the header + * above, with "assigns \nothing". Nothing to add. + */ diff --git a/frama-c-stubs/macos-libc.h b/frama-c-stubs/macos-libc.h new file mode 100644 index 00000000..66efe76d --- /dev/null +++ b/frama-c-stubs/macos-libc.h @@ -0,0 +1,45 @@ +/* + * macOS libc constants Frama-C's modeled libc does not carry + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Frama-C models a portable libc, so anything Darwin-specific is absent even + * when the header it lives in is present. A source using one stops with "Cannot + * resolve variable", which is a missing declaration rather than a modeling gap: + * the value is an integer the host header would have supplied. + * + * Only what the tree actually references, and only what Frama-C lacks. A new + * one fails loudly with the same "Cannot resolve variable", which is the + * intended way to discover it belongs here. + * + * Values match Darwin's headers. They matter here in the same narrow way the + * Hypervisor stub's do: nothing proved reads them, but a value that collided + * with another arm of the same switch would make a walked branch look + * unreachable, so they are the real ones rather than placeholders. + */ + +#pragma once + +/* fcntl.h: return the path of an open fd. procemu.c and io.c use it to answer + * /proc/self/fd/N and to re-resolve a host fd. + */ +#ifndef F_GETPATH +#define F_GETPATH 50 +#endif + +/* sys/socket.h: suppress SIGPIPE per socket rather than per process. Darwin's + * answer to Linux's MSG_NOSIGNAL, which is why the socket layer reaches for it. + */ +#ifndef SO_NOSIGPIPE +#define SO_NOSIGPIPE 0x1022 +#endif + +/* errno.h: too many references, cannot splice. Darwin defines it; the modeled + * libc stops at the POSIX set. 59, as sys/errno.h and the "mac 59 -> linux 109" + * arm of linux_errno() both say. 62 is Darwin's ELOOP, which is another arm of + * that same switch, so the two must not share a value. + */ +#ifndef ETOOMANYREFS +#define ETOOMANYREFS 59 +#endif diff --git a/mk/analysis.mk b/mk/analysis.mk index 5b85ee4b..b09b544d 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -11,7 +11,8 @@ INFER ?= infer # untracked mirrors under dot-directories. C_FORMAT_FILES := $(shell git ls-files --cached --others --exclude-standard \ -- 'src/**/*.[ch]' 'src/*.[ch]' \ - 'tests/*.c' 'tests/*.h') + 'tests/*.c' 'tests/*.h' \ + 'frama-c-stubs/**/*.h' 'frama-c-stubs/*.h') SHELL_SCRIPTS := $(shell git ls-files --cached --others --exclude-standard \ -- '*.sh') PYTHON_FORMAT_FILES := $(shell git ls-files --cached --others \ @@ -113,8 +114,37 @@ FRAMAC_PROVERS ?= alt-ergo,z3 # Per-target preprocessor defines, empty for every target that does not set one. CPP_DEFS := +# Two additions, both of which decide whether a .c file can be proved at all. +# +# frama-c-stubs supplies Hypervisor/Hypervisor.h. Frama-C has no Apple SDK, so +# every source reaching src/core/guest.h or src/runtime/thread.h aborted on the +# missing header before parsing began. See the stub for what it declares. +# +# -include gcc-atomics.h covers everything the analyzer needs and a compiler +# provides for free. It supplies the type-generic __atomic_*_n builtins, which +# Frama-C's libc does not model at all; without them those calls are implicit +# declarations whose argument types are inferred per translation unit, which +# parses one file at a time and then refuses the moment two files disagree on a +# width. It also pulls in __fc_gcc_builtins.h for the builtins Frama-C does +# model, and stdatomic.h for the _Atomic qualifier its front end cannot parse -- +# that header is where Frama-C states "_Atomic is currently ignored", so the +# concession is the analyzer's own rather than one invented here. The tree +# includes stdatomic.h nowhere, so it has to arrive ahead of the source. +# +# Ignoring _Atomic is sound for exactly the reasoning these targets do, which is +# per-function runtime-error and bounds obligations under WP, one thread at a +# time. It would NOT be sound for a concurrency analysis. No target here is one. +# See the stub for the same limit on the atomic builtins. +# +# Together these took the parsing set from 2 sources to 15 of the tree's 55. +# The other 40 stop on macOS headers Frama-C's libc does not model (sys/mount.h, +# sys/event.h, sys/sysctl.h, sys/xattr.h, sys/attr.h, sys/spawn.h), which is a +# real modeling gap rather than a missing declaration. +FRAMAC_STUB_DIR := frama-c-stubs + FRAMAC_CPP_ARGS = -nostdinc \ - -isystem $$($(FRAMAC) -print-share-path)/libc -Isrc -I$(BUILD_DIR) \ + -isystem $$($(FRAMAC) -print-share-path)/libc \ + -I$(FRAMAC_STUB_DIR) -include gcc-atomics.h -include macos-libc.h -Isrc -I$(BUILD_DIR) \ $(CPP_DEFS) # One proof per attacker-facing parser. Each is declared by a single From 8573ab5f5b6a50136c3270ea4ab63396fd3b64e8 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:30:45 +0800 Subject: [PATCH 08/15] Gate the stub constants against the macOS SDK frama-c-stubs/macos-libc.h says its values are Darwin's real ones so they cannot collide with another arm of a switch the proofs walk. That claim was wrong the day it was written: ETOOMANYREFS was given 62, which is Darwin's ELOOP, and both are arms of the same linux_errno switch. Nothing caught it because nothing was checking, and a build never will. The analyzer does not link, so a wrong value cannot fail one; it changes what the proofs reason about instead. Two arms sharing a value makes one look unreachable, and a proof over that switch is then about a program nobody ships. That is the shape of error this tree already gates with a script rather than a comment. Hung off $(VERIFY_RULES) rather than the top-level verify target, which is what makes it run at all: CI invokes "make verify-" per matrix leg and "make verify-mutants", never bare "make verify". As a phony prerequisite shared by every rule it still executes once per invocation, so proving one target pays for one SDK query. --- mk/analysis.mk | 13 +++- scripts/check-stub-constants.py | 131 ++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 scripts/check-stub-constants.py diff --git a/mk/analysis.mk b/mk/analysis.mk index b09b544d..903deb28 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -2,7 +2,7 @@ .PHONY: lint analyze check-format indent verify \ check-contracts verify-mutants check-char-signedness \ - print-verify-targets infer-uninit + check-stub-constants print-verify-targets infer-uninit CLANG_TIDY ?= clang-tidy INFER ?= infer @@ -383,7 +383,7 @@ $(foreach t,$(VERIFY_TARGETS),$(eval $(call verify-target-vars,$(t)))) # lives in scripts/check-wp-result.py: as a shell recipe it needed every $ # doubled and every line continued, which put the gate that matters out of # reach of any test. -$(VERIFY_RULES): | $(BUILD_DIR) +$(VERIFY_RULES): check-stub-constants | $(BUILD_DIR) @command -v $(FRAMAC) >/dev/null 2>&1 || { \ printf "$(RED)frama-c not found$(RESET) "; \ printf "(set FRAMAC=, or eval \$$(opam env --switch=))\n"; \ @@ -472,6 +472,15 @@ VERIFY_JOBS ?= $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || ech # for the filter below to find. Apple's /usr/bin/make is 3.81 and records # nothing for -j1, so there it reads as a plain invocation and parallelizes; # VERIFY_JOBS=1 asks for serial in a way both understand. +## Assert every frama-c-stubs constant matches the macOS SDK +# +# The stub headers claim to carry Darwin's real values, and the analyzer never +# links, so a wrong one cannot fail a build: it silently changes what the proofs +# reason about. ETOOMANYREFS was written as 62, which is Darwin's ELOOP and +# another arm of the same linux_errno() switch, and only a review caught it. +check-stub-constants: + $(Q)python3 scripts/check-stub-constants.py + verify: +@$(MAKE) --no-print-directory \ $(if $(filter -j%,$(MAKEFLAGS)),,-j$(VERIFY_JOBS)) $(VERIFY_RULES) diff --git a/scripts/check-stub-constants.py b/scripts/check-stub-constants.py new file mode 100644 index 00000000..07e26c55 --- /dev/null +++ b/scripts/check-stub-constants.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Fail when a stub constant disagrees with the macOS SDK it claims to copy. + +frama-c-stubs/macos-libc.h supplies Darwin constants Frama-C's portable libc +omits, and its header says the values are the real ones rather than +placeholders. That claim was wrong the day it was written: ETOOMANYREFS was +given 62, which is Darwin's ELOOP, and both are arms of the same linux_errno() +switch. Nothing caught it, because nothing was checking. + +The analyzer never links against the SDK, so a wrong value cannot break a +build. It quietly changes what the proofs reason about instead: two arms of a +walked switch sharing a value makes one of them look unreachable, and a proof +over that switch is then about a program nobody ships. + +Skips rather than fails when no SDK is present, so a Linux checkout can still +run the rest of the gates. A macOS CI run has one. + +Usage: + check-stub-constants.py [--stub PATH] +""" + +import argparse +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DEFAULT_STUB = ROOT / "frama-c-stubs" / "macos-libc.h" + +# Only object-like defines with an integer value. A macro with parameters or a +# non-numeric body is not a constant this can compare, and is reported as +# unchecked rather than silently passed. +DEFINE = re.compile(r"^#define\s+([A-Z_][A-Z_0-9]*)\s+(0x[0-9a-fA-F]+|\d+)\s*$", re.M) + + +def sdk_path(): + try: + out = subprocess.run( + ["xcrun", "--show-sdk-path"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + return None + path = pathlib.Path(out.stdout.strip()) if out.returncode == 0 else None + return path if path and (path / "usr" / "include").is_dir() else None + + +def sdk_values(include_dir, names): + """{name: {values the SDK defines it as}} for every @names, in one pass. + + Searched across the whole include tree rather than named headers: the stub's + comment says which header each constant comes from, and pinning that here + would just be a second copy of the same claim to keep in step. + + One grep for all of them rather than one each. The tree is about 3,400 files + and a scan costs roughly 0.75s, which a per-constant loop multiplied by the + number of stub constants for no reason. + """ + pattern = r"^#define[ \t]+(" + "|".join(names) + r")[ \t]+(0x[0-9a-fA-F]+|[0-9]+)" + hit = subprocess.run( + ["grep", "-rhoE", pattern, str(include_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ).stdout.splitlines() + found = {name: set() for name in names} + for line in hit: + parts = line.split() + if len(parts) >= 3 and parts[1] in found: + found[parts[1]].add(int(parts[2], 0)) + return found + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--stub", default=str(DEFAULT_STUB)) + args = ap.parse_args() + + stub = pathlib.Path(args.stub) + if not stub.is_file(): + print(f" stub not found: {stub}") + return 1 + + sdk = sdk_path() + if sdk is None: + print(" STUBCONST no macOS SDK; skipping (a macOS run checks this)") + return 0 + include_dir = sdk / "usr" / "include" + + defines = DEFINE.findall(stub.read_text()) + if not defines: + print(f" no integer defines found in {stub}; the regex or the file moved") + return 1 + + found = sdk_values(include_dir, [name for name, _ in defines]) + wrong, missing = [], [] + for name, raw in defines: + want = int(raw, 0) + got = found[name] + if not got: + missing.append(name) + elif got != {want}: + # One value that disagrees, or several headers disagreeing with each + # other; either way there is nothing here that matches the stub. + wrong.append((name, want, sorted(got))) + + if wrong: + print(" stub constants disagree with the macOS SDK:") + for name, want, got in wrong: + print(f" {name}: stub {want} ({hex(want)}), SDK {got}") + print(" The analyzer never links, so this changes what the proofs") + print(" reason about rather than what runs. Use the SDK value.") + return 1 + + if missing: + print(" stub constants the SDK does not define uniquely:") + for name in missing: + print(f" {name}") + print(" Either the name is wrong or it is not an SDK constant; if it") + print(" is deliberately synthetic, it does not belong in this file.") + return 1 + + print(f" STUBCONST {len(defines)} stub constant(s) match the macOS SDK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From fbfcf1d6663e773db79216437c8da9866ad7eb91 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:32:06 +0800 Subject: [PATCH 09/15] Stop nl_put_attr truncating its own extent The extent was computed as "(uint16_t) (RTA_HDRLEN + datalen)" and only the aligned result was checked against the space left. For datalen at or above 65532 that cast wraps: the total comes back as 3 or less, the aligned value as 4, the space check passes, and the payload memcpy then writes up to 65535 bytes into a buffer with 4 bytes free. Nothing reaches it. The five call sites pass 4, 16, or an interface name from getifaddrs. That is unreachable by caller provenance, which is the argument netlink_msg_span directly above it was written to stop relying on, so this gets the same treatment rather than a comment. netlink_attr_extent states that a non-zero result means the total is RTA_HDRLEN + datalen, fits the 16-bit wire field, and that the aligned extent fits the buffer. The caller's remaining cast to rta_len is then lossless exactly when it returned non-zero, and both writes land inside max. Both halves of the guard carry weight and both are mutation covered: dropping the wire-field ceiling reproduces the wrap, dropping the space check runs the payload past the buffer. --- mk/analysis.mk | 7 ++--- scripts/check-mutants.py | 16 ++++++++++++ src/proved/netlink.h | 56 ++++++++++++++++++++++++++++++++++++++++ src/syscall/netlink.c | 15 ++++++----- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/mk/analysis.mk b/mk/analysis.mk index 903deb28..d3eae5aa 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -245,12 +245,13 @@ VERIFY_SOCKADDR_CLAIM := for ANY address length a guest or host can present VERIFY_SOCKADDR_UNPROVED := the family translation and memcpy stay test-covered VERIFY_NETLINK_SRC := src/proved/netlink.h -VERIFY_NETLINK_FCTS := netlink_align_up netlink_rta_bounds netlink_msg_span -VERIFY_NETLINK_MIN_GOALS ?= 44 +VERIFY_NETLINK_FCTS := netlink_align_up netlink_rta_bounds netlink_msg_span \ + netlink_attr_extent +VERIFY_NETLINK_MIN_GOALS ?= 65 VERIFY_NETLINK_MODEL := typed VERIFY_NETLINK_SCAN := src/proved/netlink.h VERIFY_NETLINK_CLAIM := for ANY netlink message bytes a guest can send -VERIFY_NETLINK_UNPROVED := the walk loops and attribute copies stay test-covered +VERIFY_NETLINK_UNPROVED := the attribute copies stay test-covered VERIFY_SIGFRAME_SRC := src/proved/sigframe.h VERIFY_SIGFRAME_FCTS := sigframe_base diff --git a/scripts/check-mutants.py b/scripts/check-mutants.py index 670e8482..196ca803 100755 --- a/scripts/check-mutants.py +++ b/scripts/check-mutants.py @@ -410,6 +410,22 @@ def _load_analysis_mk(): " uint64_t padded = len + (NETLINK_ALIGNTO - 1);", " uint64_t padded = len;", ), + ( + "netlink", + "src/proved/netlink.h", + "netlink_attr_extent", + "drop the wire-field ceiling (the caller's cast to rta_len truncates)", + " if (datalen > NETLINK_ATTR_LEN_MAX - RTA_HDRLEN)\n return 0;\n\n", + "", + ), + ( + "netlink", + "src/proved/netlink.h", + "netlink_attr_extent", + "drop the remaining-space guard (the payload runs past the buffer)", + " if (a > max)\n return 0;\n\n", + "", + ), # ---- verify-sigframe --------------------------------------------------- ( "sigframe", diff --git a/src/proved/netlink.h b/src/proved/netlink.h index 4778ed00..205e3765 100644 --- a/src/proved/netlink.h +++ b/src/proved/netlink.h @@ -155,3 +155,59 @@ static inline int netlink_msg_span(uint64_t nlmsg_len, uint64_t *span) *span = netlink_align_up(nlmsg_len); return 1; } + +/* Ceiling on one attribute's length field, which is 16 bits on the wire. */ +#define NETLINK_ATTR_LEN_MAX 0xFFFFULL + +/* Header-plus-payload extent for one outgoing rtattr, or 0 when the payload + * does not fit the wire length field or the remaining buffer. + * + * nl_put_attr computed this as "(uint16_t) (RTA_HDRLEN + datalen)" and checked + * only the ALIGNED result against the space left. For datalen >= 65532 the cast + * wraps: the total comes back as 3 or less, the aligned value as 4, the space + * check passes, and the payload memcpy then writes up to 65535 bytes into a + * buffer with 4 bytes free. Its five call sites all pass 4, 16, or an interface + * name from getifaddrs, so nothing reaches it -- unreachable by caller + * provenance, which is the same argument netlink_msg_span above was written to + * stop relying on. + * + * Both halves of the guard carry weight. Without the wire-field half the total + * wraps as before; without the space half the write runs past the buffer. The + * result is uint64_t, so the caller's remaining cast to uint16_t is lossless + * exactly when this returned non-zero, which is what the total ceiling states. + */ +/*@ + requires \valid(total); + requires \valid(aligned); + requires \separated(total, aligned); + assigns *total, *aligned; + ensures \result == 0 || \result == 1; + ensures \result != 0 <==> + (datalen <= NETLINK_ATTR_LEN_MAX - RTA_HDRLEN && + (RTA_HDRLEN + datalen + (NETLINK_ALIGNTO - 1)) + - (RTA_HDRLEN + datalen + (NETLINK_ALIGNTO - 1)) + % NETLINK_ALIGNTO <= max); + ensures \result != 0 ==> *total == RTA_HDRLEN + datalen; + ensures \result != 0 ==> *aligned >= *total; + ensures \result != 0 ==> *aligned < *total + NETLINK_ALIGNTO; + ensures \result != 0 ==> *aligned % NETLINK_ALIGNTO == 0; + ensures \result == 0 ==> *total == \old(*total); + ensures \result == 0 ==> *aligned == \old(*aligned); + */ +static inline int netlink_attr_extent(uint64_t datalen, + uint64_t max, + uint64_t *total, + uint64_t *aligned) +{ + if (datalen > NETLINK_ATTR_LEN_MAX - RTA_HDRLEN) + return 0; + + uint64_t t = RTA_HDRLEN + datalen; + uint64_t a = netlink_align_up(t); + if (a > max) + return 0; + + *total = t; + *aligned = a; + return 1; +} diff --git a/src/syscall/netlink.c b/src/syscall/netlink.c index 3de2e7f0..0d0ca551 100644 --- a/src/syscall/netlink.c +++ b/src/syscall/netlink.c @@ -183,17 +183,20 @@ static size_t nl_put_attr(uint8_t *buf, const void *data, uint16_t datalen) { - uint16_t total = (uint16_t) (RTA_HDRLEN + datalen); - uint16_t aligned = (uint16_t) netlink_align_up(total); - if (aligned > max) + /* Proved in src/proved/netlink.h: on success total is RTA_HDRLEN + datalen + * and fits the 16-bit wire field, and aligned is at most max. The cast to + * rta_len is therefore lossless and both writes below land inside max. + */ + uint64_t total, aligned; + if (!netlink_attr_extent(datalen, max, &total, &aligned)) return 0; - rtattr_t rta = {.rta_len = total, .rta_type = type}; + rtattr_t rta = {.rta_len = (uint16_t) total, .rta_type = type}; memcpy(buf, &rta, sizeof(rta)); memcpy(buf + RTA_HDRLEN, data, datalen); /* Zero padding */ if (aligned > total) - memset(buf + total, 0, aligned - total); - return aligned; + memset(buf + total, 0, (size_t) (aligned - total)); + return (size_t) aligned; } /* Build RTM_GETLINK response from host getifaddrs(). A non-empty name_filter or From 7d9098f6b82e1d01b7848b402b932077cb00b3df Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:35:47 +0800 Subject: [PATCH 10/15] Prove the netlink walk loops netlink.h proved the rtattr arithmetic and its UNPROVED line said the walks around it stayed test-covered. Both can be proved now that netlink.c parses. nl_parse_link_filter reads an rtattr chain out of bytes the guest wrote; nl_complete_span walks the reply buffer. Their contracts state what the callers already guarantee, and the loop annotations state what the walk maintains: for the parse, that off stays bounded by reqlen so the C loop test cannot wrap, and that the name copy leaves room for its terminator. nl_put_attr's payload precondition restates memcpy's own predicate from Frama-C's string.h rather than a hand-written \valid_read, because datalen may be 0 and an empty range says nothing about the pointer while memcpy still demands \object_pointer. The memory model is Bytes, neither of the two already here. Both walks memcpy a wire header out of a uint8_t buffer at an offset the message chose, so typed leaves every memcpy validity and separation goal open at 58 of 74, and caveat cannot size the byte array at all. Typed+cast reaches 73 of 74. The four helpers are re-proved rather than assumed, for the reason VERIFY_UTILS_FCTS exists. 198 obligations, and three mutations covering what the annotations are there for: the terminator's room, the fits-the-copy guard, and the padding extent. nl_parse_link_filter joins CHAR_PARAM_ALLOWLIST. Its char * is an output buffer it only writes, fed from a uint8_t source through an explicit cast, so it reads no plain char and the gcc_x86_64 signedness cannot reach it. The compiler-based check agrees for all seven. --- mk/analysis.mk | 26 +++++++++++++++- scripts/check-acsl-coverage.py | 12 ++++++-- scripts/check-mutants.py | 25 +++++++++++++++ src/proved/netlink.h | 10 +++--- src/syscall/netlink.c | 56 +++++++++++++++++++++++++++++++++- 5 files changed, 120 insertions(+), 9 deletions(-) diff --git a/mk/analysis.mk b/mk/analysis.mk index d3eae5aa..fb007911 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -253,6 +253,30 @@ VERIFY_NETLINK_SCAN := src/proved/netlink.h VERIFY_NETLINK_CLAIM := for ANY netlink message bytes a guest can send VERIFY_NETLINK_UNPROVED := the attribute copies stay test-covered +# The two walk loops in netlink.c itself, which verify-netlink above could only +# describe as "test-covered". This is the first target whose source is a .c file +# that needed the Hypervisor stub and -D_Atomic= to parse at all; see +# FRAMAC_CPP_ARGS. The proved helpers are re-proved here rather than assumed, +# for the same reason VERIFY_UTILS_FCTS exists. +VERIFY_NETLINKWALK_SRC := src/syscall/netlink.c +VERIFY_NETLINKWALK_FCTS := nl_parse_link_filter nl_complete_span nl_put_attr \ + netlink_align_up netlink_rta_bounds \ + netlink_msg_span netlink_attr_extent +VERIFY_NETLINKWALK_MIN_GOALS ?= 190 + +# Bytes, and neither of the two models already in this file. Both walks memcpy a +# wire header out of a uint8_t buffer at an offset the message itself chose, so +# typed leaves every memcpy validity and separation goal open (58 of 74), and +# caveat cannot size the byte array at all ("Undefined array-size"). Typed+cast +# gets 73 of 74. Bytes models memory as bytes, which is what the code does, and +# closes all of them. +VERIFY_NETLINKWALK_MODEL := Bytes +VERIFY_NETLINKWALK_SCAN := src/syscall/netlink.c src/proved/netlink.h +VERIFY_NETLINKWALK_CLAIM := for ANY rtattr chain a guest can write and ANY \ +reply buffer state +VERIFY_NETLINKWALK_UNPROVED := the reply builders, the socket I/O, and whether \ +their callers honor these preconditions stay test-covered + VERIFY_SIGFRAME_SRC := src/proved/sigframe.h VERIFY_SIGFRAME_FCTS := sigframe_base VERIFY_SIGFRAME_MIN_GOALS ?= 15 @@ -334,7 +358,7 @@ commafy = $(subst $(verify_space),$(verify_comma),$(strip $(1))) # target name is enough and stays readable; a new target using a letter not # listed here shows up immediately as a literal upper-case character in the # rule name rather than silently misbehaving. -lc = $(subst A,a,$(subst B,b,$(subst C,c,$(subst D,d,$(subst E,e,$(subst F,f,$(subst G,g,$(subst H,h,$(subst I,i,$(subst K,k,$(subst L,l,$(subst M,m,$(subst N,n,$(subst O,o,$(subst P,p,$(subst Q,q,$(subst R,r,$(subst S,s,$(subst T,t,$(subst U,u,$(subst V,v,$(subst X,x,$(1))))))))))))))))))))))) +lc = $(subst A,a,$(subst B,b,$(subst C,c,$(subst D,d,$(subst E,e,$(subst F,f,$(subst G,g,$(subst H,h,$(subst I,i,$(subst K,k,$(subst L,l,$(subst M,m,$(subst N,n,$(subst O,o,$(subst P,p,$(subst Q,q,$(subst R,r,$(subst S,s,$(subst T,t,$(subst U,u,$(subst V,v,$(subst W,w,$(subst X,x,$(1)))))))))))))))))))))))) # The proof targets, derived rather than listed. Make knows every variable it # has read, so the set of VERIFY__SRC assignments above IS the target list; diff --git a/scripts/check-acsl-coverage.py b/scripts/check-acsl-coverage.py index e676ce4b..cc8ef829 100644 --- a/scripts/check-acsl-coverage.py +++ b/scripts/check-acsl-coverage.py @@ -115,14 +115,20 @@ def contracted_definitions(text): # This regex is the cheap half and it names the offending function, which is # what makes a failure actionable. It cannot see a char reached through a # typedef or a macro; check-char-signedness.py settles that half with the -# compiler, which resolves both, and "make verify" runs it. The four below -# predate the invariant and satisfy it by casting at every use, so they are -# listed and hand-audited rather than rewritten. +# compiler, which resolves both, and "make verify" runs it. The five below +# satisfy the invariant and are listed and hand-audited rather than rewritten. +# +# The four RSP ones predate the invariant and cast at every read. +# nl_parse_link_filter is a different shape: its char * is an output buffer it +# only ever writes, and the byte it writes comes from a uint8_t source through +# an explicit (char) cast. It reads no plain char at all, so there is nothing +# for the signedness to change. CHAR_PARAM_ALLOWLIST = { "gdb_hex_pair", "gdb_hex_decode", "gdb_parse_hex", "rsp_checksum", + "nl_parse_link_filter", } # A char parameter, with its signedness qualifier if it has one. Matching the diff --git a/scripts/check-mutants.py b/scripts/check-mutants.py index 196ca803..f91210a6 100755 --- a/scripts/check-mutants.py +++ b/scripts/check-mutants.py @@ -426,6 +426,31 @@ def _load_analysis_mk(): " if (a > max)\n return 0;\n\n", "", ), + # ---- verify-netlinkwalk ------------------------------------------------ + ( + "netlinkwalk", + "src/syscall/netlink.c", + "nl_parse_link_filter", + "leave no room for the terminator (name_out[name_cap] is written)", + " for (; i < dlen && i + 1 < name_cap && req[off + RTA_HDRLEN + i];", + " for (; i < dlen && i < name_cap && req[off + RTA_HDRLEN + i];", + ), + ( + "netlinkwalk", + "src/syscall/netlink.c", + "nl_complete_span", + "drop the fits-the-copy guard (the span reported exceeds to_copy)", + " if (msg_bytes > to_copy)\n break;\n", + "", + ), + ( + "netlinkwalk", + "src/syscall/netlink.c", + "nl_put_attr", + "pad one byte past the aligned extent (writes past max)", + " memset(buf + total, 0, (size_t) (aligned - total));", + " memset(buf + total, 0, (size_t) (aligned - total) + 1);", + ), # ---- verify-sigframe --------------------------------------------------- ( "sigframe", diff --git a/src/proved/netlink.h b/src/proved/netlink.h index 205e3765..5ecd9aeb 100644 --- a/src/proved/netlink.h +++ b/src/proved/netlink.h @@ -10,10 +10,12 @@ * reply span walk reads elfuse's own synthesized buffer, and is here for the * termination argument rather than for bounds; see below. * - * Split out of netlink.c because that file cannot be given to Frama-C: it - * includes the macOS network headers, which the analyzer's libc does not model. - * This header needs nothing but stdint.h, so make verify-netlink proves it - * directly. + * Split out of netlink.c when that file could not be given to Frama-C at all. + * It can now, under make verify-netlinkwalk, which proves the two walk loops + * that call into here; this header stays separate because it needs nothing but + * stdint.h and so proves in a second, while netlink.c drags in the whole + * syscall layer. The two targets overlap on purpose: verify-netlinkwalk + * re-proves these functions rather than assuming them. */ #pragma once diff --git a/src/syscall/netlink.c b/src/syscall/netlink.c index 0d0ca551..81deddd8 100644 --- a/src/syscall/netlink.c +++ b/src/syscall/netlink.c @@ -176,7 +176,21 @@ static void netlink_clear_readable(netlink_state_t *ns) } } -/* Append a netlink attribute to the buffer. Returns bytes written. */ +/* Append a netlink attribute to the buffer. + * + * Returns bytes written. The payload precondition is memcpy's own predicate + * from Frama-C's string.h, not a hand-written \valid_read. datalen may be 0, + * and an empty \valid_read range says nothing at all about the pointer, while + * memcpy still demands \object_pointer on it. Restating the predicate is what + * keeps the two in step. + */ +/*@ + requires \valid(buf + (0 .. max - 1)); + requires valid_read_or_empty(data, datalen); + requires \separated(buf + (0 .. max - 1), (char *) data + (0 .. datalen - 1)); + assigns buf[0 .. max - 1]; + ensures \result <= max; + */ static size_t nl_put_attr(uint8_t *buf, size_t max, uint16_t type, @@ -486,6 +500,17 @@ int64_t netlink_bind(int guest_fd, /* Extract the LinkByName/LinkByIndex filter (ifi_index plus an optional * IFLA_IFNAME) from a RTM_GETLINK request. Empty name / zero index = no filter. */ +/*@ + requires \valid_read(req + (0 .. reqlen - 1)); + requires \valid(name_out + (0 .. name_cap - 1)); + requires \valid(index_out); + requires name_cap > 0; + requires reqlen <= NETLINK_LEN_MAX; + requires \separated(name_out + (0 .. name_cap - 1), + req + (0 .. reqlen - 1), + index_out); + assigns name_out[0 .. name_cap - 1], *index_out; + */ static void nl_parse_link_filter(const uint8_t *req, size_t reqlen, char *name_out, @@ -508,6 +533,16 @@ static void nl_parse_link_filter(const uint8_t *req, size_t total = (nlmsg_len < reqlen) ? nlmsg_len : reqlen; size_t off = NLMSG_HDRLEN + netlink_align_up(sizeof(ifinfomsg_t)); + + /* The invariant bounds off itself, not just off relative to total. + * Without it the C loop test "off + RTA_HDRLEN <= total" is not known to be + * free of unsigned wrap, and every goal under the loop inherits that doubt. + */ + /*@ + loop invariant off <= reqlen + NETLINK_ALIGNTO; + loop assigns off, name_out[0 .. name_cap - 1]; + loop variant total - off; + */ while (off + RTA_HDRLEN <= total) { rtattr_t rta; memcpy(&rta, req + off, sizeof(rta)); @@ -522,6 +557,11 @@ static void nl_parse_link_filter(const uint8_t *req, if (rta.rta_type == IFLA_IFNAME) { size_t dlen = (size_t) data_len; size_t i = 0; + /*@ + loop invariant i < name_cap; + loop assigns i, name_out[0 .. name_cap - 1]; + loop variant dlen - i; + */ for (; i < dlen && i + 1 < name_cap && req[off + RTA_HDRLEN + i]; i++) name_out[i] = (char) req[off + RTA_HDRLEN + i]; @@ -723,9 +763,23 @@ static int64_t nl_wait_readable_locked(netlink_state_t *ns, * starts at ns->buf_pos and fits within to_copy. Falls back to to_copy when not * even one whole message fits (MSG_TRUNC semantics). Called with nl_lock held. */ +/*@ + requires \valid_read(ns); + requires ns->buf_pos <= ns->buf_len; + requires ns->buf_len <= NETLINK_BUF_SIZE; + requires to_copy <= ns->buf_len - ns->buf_pos; + assigns \nothing; + ensures \result <= to_copy; + */ static size_t nl_complete_span(const netlink_state_t *ns, size_t to_copy) { size_t msg_end = 0, pos = ns->buf_pos; + /*@ + loop invariant ns->buf_pos <= pos <= ns->buf_len; + loop invariant msg_end <= to_copy; + loop assigns pos, msg_end; + loop variant ns->buf_len - pos; + */ while (pos < ns->buf_len && (pos - ns->buf_pos + NLMSG_HDRLEN) <= to_copy) { /* memcpy rather than a cast: pos is not guaranteed to sit on a message * boundary (see the header), so ns->buf + pos need not be suitably From 9daad74b25e750578a33a18e9631398b882c8431 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:36:09 +0800 Subject: [PATCH 11/15] Rename the va_start and va_end locals mem.c, guest.c and rosetta.c used va_start and va_end as local variable and parameter names for the ends of an address range. They shadow the stdarg macros. It compiles, since both are function-like and a declaration never invokes them, but procemu.c two directories away calls the real ones, and Frama-C rejects the file outright under CERT MSC38-C, which is what kept mem.c, the largest source in the tree, out of the analyzer. va_base and va_limit instead. rosetta.c already used va_base for the start of exactly this kind of range, and limit is the word the tree uses for an exclusive upper bound in mmap_limit and backing_limit. The four genuine va_start(ap, ...) call sites are untouched. --- src/core/guest.c | 29 +++++++------- src/core/guest.h | 15 ++++---- src/core/rosetta.c | 8 ++-- src/syscall/mem.c | 94 ++++++++++++++++++++++++---------------------- 4 files changed, 78 insertions(+), 68 deletions(-) diff --git a/src/core/guest.c b/src/core/guest.c index a7f76cde..350c45b7 100644 --- a/src/core/guest.c +++ b/src/core/guest.c @@ -1119,18 +1119,18 @@ int guest_init_kbuf(guest_t *g, uint64_t kbuf_gpa) static uint64_t make_block_desc(uint64_t gpa, int perms); int guest_map_va_range(guest_t *g, - uint64_t va_start, - uint64_t va_end, + uint64_t va_base, + uint64_t va_limit, uint64_t gpa_start, int perms) { - if (!g || va_end <= va_start) + if (!g || va_limit <= va_base) return -1; - if ((va_start | va_end | gpa_start) & (BLOCK_2MIB - 1)) { + if ((va_base | va_limit | gpa_start) & (BLOCK_2MIB - 1)) { log_error( "guest_map_va_range: arguments not 2 MiB aligned " "(va=[0x%llx,0x%llx) gpa=0x%llx)", - (unsigned long long) va_start, (unsigned long long) va_end, + (unsigned long long) va_base, (unsigned long long) va_limit, (unsigned long long) gpa_start); return -1; } @@ -1147,7 +1147,7 @@ int guest_map_va_range(guest_t *g, bool bcast = tlbi_request_is_broadcast(); if (perms & MEM_PERM_X) tlbi_request_mark_icache(); - for (uint64_t va = va_start; va < va_end; + for (uint64_t va = va_base; va < va_limit; va += BLOCK_2MIB, cur_gpa += BLOCK_2MIB) { unsigned l0_idx = (unsigned) (va / (512ULL * BLOCK_1GIB)); if (l0_idx >= 512) { @@ -1894,12 +1894,12 @@ int guest_get_used_regions(const guest_t *g, * Check whether two adjacent regions have merge-compatible layouts. An actual * merge additionally requires a shared vma_id; a new same-generation mapping * may adopt its compatible neighbor's ID below. Regions must be contiguous in - * address space, have identical protection/flags/name, and have contiguous - * file offsets (so the merged region still represents valid mapping). For - * anonymous regions the offset is meaningless (always 0, but may become - * non-zero after split/trim), so the contiguity check is skipped. Without this, - * adjacent anonymous mmaps (common in megablock-style allocators) each create - * separate entries that exhaust the region table. + * address space, have identical protection/flags/name, and have contiguous file + * offsets (so the merged region still represents valid mapping). For anonymous + * regions the offset is meaningless (always 0, but may become non-zero after + * split/trim), so the contiguity check is skipped. Without this, adjacent + * anonymous mmaps (common in megablock-style allocators) each create separate + * entries that exhaust the region table. */ static bool regions_mergeable_layout(const guest_region_t *a, const guest_region_t *b) @@ -2245,8 +2245,9 @@ int guest_region_remove_prepare(guest_t *g, if (r->start >= end) break; if (r->start < start && r->end > end) { - /* A full table follows the existing stale-tracker fallback and - * does not publish a right-hand record, so no fd is required. */ + /* A full table follows the existing stale-tracker fallback and does + * not publish a right-hand record, so no fd is required. + */ if (g->nregions >= GUEST_MAX_REGIONS || r->backing_fd < 0) return 0; *reserved_backing_fd = dup(r->backing_fd); diff --git a/src/core/guest.h b/src/core/guest.h index c16dca56..7d4fe147 100644 --- a/src/core/guest.h +++ b/src/core/guest.h @@ -935,8 +935,8 @@ int guest_init_kbuf(guest_t *g, uint64_t kbuf_gpa); */ int guest_install_kbuf_user_alias(guest_t *g); -/* Install L2 block descriptors mapping [va_start, va_end) to [gpa_start, - * gpa_start + (va_end-va_start)) under TTBR0. Both addresses and the size must +/* Install L2 block descriptors mapping [va_base, va_limit) to [gpa_start, + * gpa_start + (va_limit-va_base)) under TTBR0. Both addresses and the size must * be 2 MiB-aligned. Walks the existing TTBR0 tree at g->ttbr0 and allocates * L1/L2 tables from the PT pool as needed. * @@ -956,8 +956,8 @@ int guest_install_kbuf_user_alias(guest_t *g); * Locking: callers MUST hold mmap_lock. */ int guest_map_va_range(guest_t *g, - uint64_t va_start, - uint64_t va_end, + uint64_t va_base, + uint64_t va_limit, uint64_t gpa_start, int perms); @@ -1216,7 +1216,8 @@ int guest_region_add_ex_owned_gpa(guest_t *g, /* Re-seed the logical-VMA allocator from a restored region snapshot. Fork IPC * restores regions by value, so next_vma_id must be advanced past every - * serialized lineage before child-private mappings are added. */ + * serialized lineage before child-private mappings are added. + */ void guest_reseed_next_vma_id(guest_t *g); /* Add a preannounced region that appears in /proc/self/maps only. These entries @@ -1237,8 +1238,8 @@ int guest_preannounce(guest_t *g, uint64_t offset, const char *name); -/* Reserve any backing fd needed by an interior split in [start, end). - * The reservation must be consumed by guest_region_remove_reserved(). +/* Reserve any backing fd needed by an interior split in [start, end). The + * reservation must be consumed by guest_region_remove_reserved(). * Returns 0 on success, -1 when the backing fd cannot be duplicated. */ int guest_region_remove_prepare(guest_t *g, diff --git a/src/core/rosetta.c b/src/core/rosetta.c index c8a3871d..311dd8ab 100644 --- a/src/core/rosetta.c +++ b/src/core/rosetta.c @@ -217,12 +217,12 @@ int rosetta_prepare(guest_t *g, * resolve through a single Stage-2 region. */ uint64_t va_base = ALIGN_2MIB_DOWN(ri->load_min); - uint64_t va_end = ALIGN_2MIB_UP(ri->load_max); - if (va_end <= va_base) { + uint64_t va_limit = ALIGN_2MIB_UP(ri->load_max); + if (va_limit <= va_base) { log_error("rosetta: empty load range"); return -1; } - uint64_t size = va_end - va_base; + uint64_t size = va_limit - va_base; /* Pick a primary-buffer placement below the full high-IPA infra reserve, 2 * MiB aligned. guest_init() has already reserved [interp_base - @@ -734,6 +734,7 @@ static ssize_t rosettad_recv_fd(int sock, void *buf, size_t buflen, int *out_fd) if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS && cmsg->cmsg_len >= CMSG_LEN(0)) { size_t payload = cmsg->cmsg_len - CMSG_LEN(0); + /* Cap by the fds that actually fit after the header so a corrupt * cmsg_len cannot drive the memcpy source past the end of cmsg_buf. */ @@ -1141,6 +1142,7 @@ int rosettad_start_handler(int handler_fd, int client_fd) { if (handler_fd < 0 || client_fd < 0) return -1; + /* Only one bridge per process is supported. Claim the slot via a single * atomic compare-exchange so two threads cannot both observe -1 and then * race to install their own client fd; the loser sees the winner's fd diff --git a/src/syscall/mem.c b/src/syscall/mem.c index e237409d..522bf178 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -217,8 +217,8 @@ static void mark_overlay_metadata_range(guest_t *g, /* Mark every region overlapping [start, end) as backed by a fd that lost write * access, so sys_mprotect rejects a later PROT_WRITE upgrade. mremap can split - * an inherited VMA at the fork boundary, so callers must not require one - * exact region match here. + * an inherited VMA at the fork boundary, so callers must not require one exact + * region match here. */ static void mark_region_backing_ro(guest_t *g, uint64_t start, uint64_t end) { @@ -288,9 +288,9 @@ static int64_t finish_mremap(mremap_source_t *source, int64_t result) /* Resolve a logical mremap source. Fork-aware growth intentionally leaves an * inherited prefix and a child-private tail as separate records; those two * records are still one VMA for mremap purposes. The stable vma_id proves that - * provenance even after another fork changes inherited_at_fork on both - * records. Reject any boundary with a different lineage so unrelated adjacent - * mappings cannot be copied as one source. + * provenance even after another fork changes inherited_at_fork on both records. + * Reject any boundary with a different lineage so unrelated adjacent mappings + * cannot be copied as one source. */ static int find_mremap_source(const guest_t *g, uint64_t start, @@ -883,7 +883,7 @@ static int64_t sys_mmap_high_va(guest_t *g, * page-rounds length, but addr is guest-supplied and a huge length against * a high VA can still overflow. Also reject the case where addr + length is * too close to UINT64_MAX for ALIGN_UP to round up the 2 MiB boundary - * without wrapping to 0 (which would make va_end smaller than va_start and + * without wrapping to 0 (which would make va_limit smaller than va_base and * underflow backing_span). */ if (length == 0 || addr > UINT64_MAX - length) @@ -952,14 +952,14 @@ static int64_t sys_mmap_high_va(guest_t *g, replacing_existing = true; } - uint64_t va_start = ALIGN_DOWN(addr, BLOCK_2MIB); - uint64_t va_end = ALIGN_UP(addr + length, BLOCK_2MIB); - uint64_t backing_span = va_end - va_start; + uint64_t va_base = ALIGN_DOWN(addr, BLOCK_2MIB); + uint64_t va_limit = ALIGN_UP(addr + length, BLOCK_2MIB); + uint64_t backing_span = va_limit - va_base; uint64_t backing_gpa_start = 0; uint64_t backing_limit = 0; if (replacing_existing) { - backing_gpa_start = replaced_gpa_base - (addr - va_start); + backing_gpa_start = replaced_gpa_base - (addr - va_base); } else { backing_gpa_start = ALIGN_UP((g->mmap_end > g->mmap_next) ? g->mmap_end : g->mmap_next, @@ -1025,7 +1025,7 @@ static int64_t sys_mmap_high_va(guest_t *g, (prot == LINUX_PROT_NONE) ? MEM_PERM_RW : prot_to_perms(prot); if (replacing_existing) { - map_host = host_ptr_for_gpa(g, backing_gpa_start + (addr - va_start)); + map_host = host_ptr_for_gpa(g, backing_gpa_start + (addr - va_base)); if (!map_host) goto fail; goto populate_existing; @@ -1036,10 +1036,10 @@ static int64_t sys_mmap_high_va(guest_t *g, * zero descriptors after invalidation and harmless until reused by a later * mmap. */ - va_installed_end = va_start; + va_installed_end = va_base; - for (uint64_t va = va_start; va < va_end; va += BLOCK_2MIB) { - uint64_t gpa = backing_gpa_start + (va - va_start); + for (uint64_t va = va_base; va < va_limit; va += BLOCK_2MIB) { + uint64_t gpa = backing_gpa_start + (va - va_base); void *host = host_ptr_for_gpa(g, gpa); if (!host) @@ -1083,7 +1083,7 @@ static int64_t sys_mmap_high_va(guest_t *g, } } - map_host = host_ptr_for_gpa(g, backing_gpa_start + (addr - va_start)); + map_host = host_ptr_for_gpa(g, backing_gpa_start + (addr - va_base)); if (!map_host) goto fail; @@ -1159,7 +1159,7 @@ static int64_t sys_mmap_high_va(guest_t *g, if (guest_invalidate_ptes(g, addr, addr + length) < 0) goto fail; } else { - uint64_t gpa_for_addr = backing_gpa_start + (addr - va_start); + uint64_t gpa_for_addr = backing_gpa_start + (addr - va_base); replaced_ptes_modified = replacing_existing; if (guest_install_va_pages(g, addr, length, gpa_for_addr, prot_to_perms(prot)) < 0) @@ -1174,7 +1174,7 @@ static int64_t sys_mmap_high_va(guest_t *g, g->mmap_end = backing_gpa_end; } - uint64_t gpa_base = backing_gpa_start + (addr - va_start); + uint64_t gpa_base = backing_gpa_start + (addr - va_base); if (!region_has_capacity_after_removes( g, replacing_existing ? &(remove_range_t) {addr, addr + length} : NULL, @@ -1250,7 +1250,7 @@ static int64_t sys_mmap_high_va(guest_t *g, (unsigned long long) (inflight_fresh_block_va + BLOCK_2MIB)); } } - if (va_installed_end > va_start) { + if (va_installed_end > va_base) { if (guest_invalidate_ptes(g, addr, addr + length) < 0) { log_error( "sys_mmap_high_va: rollback invalidate failed for " @@ -1263,6 +1263,7 @@ static int64_t sys_mmap_high_va(guest_t *g, close(track_backing_fd); if (replaced_remove_fd >= 0) close(replaced_remove_fd); + /* Restore region/PTE snapshots when this call mutated regions[] or the page * tables; otherwise just drop the snapshot allocation. Whichever path runs, * the common cleanup below frees snapshots and fds and resumes siblings, so @@ -1631,7 +1632,8 @@ static int capture_region_snapshots(guest_t *g, { /* Split and snapshot as one metadata transaction. A failed boundary split, * descriptor dup, or snapshot-capacity check must leave regions[] and its - * owned fds exactly as they were on entry. */ + * owned fds exactly as they were on entry. + */ region_array_txn_t txn; int txn_err = begin_region_array_txn(g, &txn); if (txn_err < 0) @@ -1697,7 +1699,8 @@ static int capture_region_snapshots(guest_t *g, /* MREMAP_FIXED may remove a destination fragment that used to share the same * tracker backing fd as a source fragment. Rebind file-backed source segments * to the owned source snapshots before destination removal, so later overlay - * restore and pread-based copies cannot observe a closed borrowed fd. */ + * restore and pread-based copies cannot observe a closed borrowed fd. + */ static int rebind_mremap_source_backings(mremap_source_t *source, const region_snapshot_t *snaps, int n) @@ -2285,7 +2288,7 @@ static int cleanup_overlays_in_range(guest_t *g, uint64_t start, uint64_t end) uint64_t host_start = ALIGN_DOWN(start, hps); uint64_t host_end = ALIGN_UP(end, hps); - /* Boundary splits are only needed to isolate live host overlays. A plain + /* Boundary splits are only needed to isolate live host overlays. A plain * guest range removal must not consume a region-table slot just to prove * that there is no overlay to tear down; when the table is full that would * turn an operation that only reduces mappings into a spurious ENOMEM. @@ -2453,11 +2456,11 @@ int64_t sys_brk(guest_t *g, uint64_t addr) * see no heap region. */ if (new_off < old_brk) { - /* Trim every semantic heap segment covered by the released suffix. - * In a fork child this may shorten or remove the private tail while - * leaving the inherited prefix intact. Keeping the tracker end equal - * to brk_current prevents a later regrowth from overlapping stale - * tail metadata. + /* Trim every semantic heap segment covered by the released suffix. In a + * fork child this may shorten or remove the private tail while leaving + * the inherited prefix intact. Keeping the tracker end equal to + * brk_current prevents a later regrowth from overlapping stale tail + * metadata. */ guest_region_remove_reserved(g, new_off, old_brk, shrink_remove_fd); } else if (new_off > g->brk_base) { @@ -2470,8 +2473,8 @@ int64_t sys_brk(guest_t *g, uint64_t addr) if (new_off > old_heap_end && heap->inherited_at_fork) { /* Keep the fork-snapshot portion separate from pages * materialized by post-fork brk growth. On later growths, - * extend the existing child-private tail rather than - * adding an overlapping range from the old boundary. + * extend the existing child-private tail rather than adding + * an overlapping range from the old boundary. */ guest_region_t *right = i + 1 < g->nregions ? &g->regions[i + 1] : NULL; @@ -2488,11 +2491,11 @@ int64_t sys_brk(guest_t *g, uint64_t addr) LINUX_PROT_READ | LINUX_PROT_WRITE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, 0, "[heap]", -1, false, heap->vma_id) < 0) { - /* Widening the inherited prefix would either overlap - * an incompatible child-private tail or mislabel new - * pages as inherited. Keep the original boundary; brk - * memory already grew successfully, so only the - * semantic tracker becomes stale. + /* Widening the inherited prefix would either overlap an + * incompatible child-private tail or mislabel new pages + * as inherited. Keep the original boundary; brk memory + * already grew successfully, so only the semantic + * tracker becomes stale. */ g->regions_tracker_stale = true; } @@ -3394,9 +3397,9 @@ int64_t sys_mremap(guest_t *g, if (guest_range_hits_infra(g, old_off, old_off + old_size)) return -LINUX_EINVAL; - /* Verify the whole source range is covered by one logical VMA. A - * fork-aware growth can split that VMA at the inherited/private boundary, - * but no unrelated adjacent mapping may be included. + /* Verify the whole source range is covered by one logical VMA. A fork-aware + * growth can split that VMA at the inherited/private boundary, but no + * unrelated adjacent mapping may be included. */ mremap_source_t source; bool collect_source_segments = @@ -3429,6 +3432,7 @@ int64_t sys_mremap(guest_t *g, if (guest_region_remove_prepare(g, tail_off, tail_end, &tail_remove_fd) < 0) return finish_mremap(&source, -LINUX_ENOMEM); + /* Restore slab backing under any tail overlay before zeroing so the * memset does not write zeros into a file. */ @@ -3438,6 +3442,7 @@ int64_t sys_mremap(guest_t *g, close(tail_remove_fd); return finish_mremap(&source, cleanup_err); } + /* Zero the trimmed region on its real backing (high-VA tails live at * gpa_base, not host_base + tail_off). */ @@ -3546,9 +3551,10 @@ int64_t sys_mremap(guest_t *g, } /* Keep both boundary captures in one transaction. The per-capture - * helper rolls back its own edits, while this outer guard also undoes - * a successful source capture if destination capture or the preflight - * shared-file flush fails afterward. */ + * helper rolls back its own edits, while this outer guard also undoes a + * successful source capture if destination capture or the preflight + * shared-file flush fails afterward. + */ region_array_txn_t capture_txn; int capture_txn_err = begin_region_array_txn(g, &capture_txn); if (capture_txn_err < 0) { @@ -4069,8 +4075,8 @@ int64_t sys_mremap(guest_t *g, } /* Copy each source segment according to its own backing state, then - * zero the extension. The new range is a fresh gap and receives no - * live overlay. + * zero the extension. The new range is a fresh gap and receives no live + * overlay. */ if (prot == LINUX_PROT_NONE) { memset((uint8_t *) g->host_base + new_off, 0, new_size); @@ -4342,9 +4348,9 @@ static int munmap_guest_range(guest_t *g, uint64_t unmap_off, uint64_t end) if (guest_range_hits_infra(g, unmap_off, end)) return -LINUX_EINVAL; - /* An interior removal from a file-backed region needs a second owned fd - * for the surviving right half. Reserve it before changing overlays, - * page tables, or host memory so descriptor exhaustion is failure-atomic. + /* An interior removal from a file-backed region needs a second owned fd for + * the surviving right half. Reserve it before changing overlays, page + * tables, or host memory so descriptor exhaustion is failure-atomic. */ int remove_fd = -1; if (guest_region_remove_prepare(g, unmap_off, end, &remove_fd) < 0) From bdc407e56620fc0326ec7a1e6ab8f8cab06744e2 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:36:10 +0800 Subject: [PATCH 12/15] Replay a reused pty row's slave count pty_keepalive_register_locked keeps guest_slave_count when it reuses a row for the minor that row already describes, because those slaves are still open. If the row had no shared segment then, the count was accumulated against nothing, and the segment it joins on this registration knows nothing about it. Each of those slaves decrements on close a total it was never added to, the shared count goes negative, and pty_slot_hung_up_locked reads that as a hangup with the slaves still open, which is the failure the row rework set out to remove. Reaching it needs pty_shared_attach to have returned NULL once, the master to close leaving the row as the pty's master-less home, and a dup, an SCM_RIGHTS adopt or a fork restore for the same minor to re-register it while the attach succeeds. The compensation is the one pty_keepalive_clear_slot_locked already makes when it hands a segment-less count to an heir that maps one, and it is guarded to the same-pty path, since every other route zeroed the count just above. --- src/runtime/procemu-pty.c | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/runtime/procemu-pty.c b/src/runtime/procemu-pty.c index 96284d8f..94bacd81 100644 --- a/src/runtime/procemu-pty.c +++ b/src/runtime/procemu-pty.c @@ -656,9 +656,29 @@ static int pty_keepalive_register_locked(int master_host_fd, * would split the aliases onto separate counters, so slaves opened through * one would be invisible to the other and the hangup would be lost. */ - if (!pty_keepalive_table[slot].shared) + if (!pty_keepalive_table[slot].shared) { pty_keepalive_table[slot].shared = pty_shared_attach( pty_keepalive_table[slot].slave_path, fresh_segment); + + /* A count kept across the reuse above was accumulated while this row + * had no segment, so the segment it has just joined knows nothing about + * it. Without this, each of those slaves decrements on close a total it + * was never added to, the shared count goes negative, and + * pty_slot_hung_up_locked reports a hangup with the slaves still open. + * Same compensation pty_keepalive_clear_slot_locked makes when it hands + * a segment-less count to an heir that maps one. + * + * Only reachable through the same_pty path: every other route zeroed + * the count just above, and a fresh_segment registration is by + * definition not one of them. + */ + if (pty_keepalive_table[slot].shared && + pty_keepalive_table[slot].guest_slave_count > 0) { + atomic_fetch_add(&pty_keepalive_table[slot].shared->slave_count, + pty_keepalive_table[slot].guest_slave_count); + atomic_store(&pty_keepalive_table[slot].shared->seen, 1); + } + } return PTY_REG_INSERTED; } From fc898f65e6f78202d2ef318b5bb4a5347dd0ea15 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:36:24 +0800 Subject: [PATCH 13/15] Count the shared pty declarations correctly The comment over them in procemu-internal.h said four and listed five. --- src/runtime/procemu-internal.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/procemu-internal.h b/src/runtime/procemu-internal.h index c2b66bd9..ba952dd0 100644 --- a/src/runtime/procemu-internal.h +++ b/src/runtime/procemu-internal.h @@ -36,8 +36,8 @@ void proc_scratch_remove_one(const char *dir); /* Provided by procemu-pty.c, used by procemu.c. * * The proc_pty_* entry points are in runtime/procemu.h because callers outside - * procemu use them too. These four are internal to the interceptor: they were - * static before the split and stay unexported beyond this pair of files. + * procemu use them too. The five below are internal to the interceptor: they + * were static before the split and stay unexported beyond this pair of files. */ /* Parse the N out of "/dev/pts/N". False when the path is not a slave. */ From 426aa1b41d458993e9d6f3bdd021b5dc8830fac5 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:36:24 +0800 Subject: [PATCH 14/15] Report the proof matrix verdict on a cancelled run verify used "if: ${{ !cancelled() }}" where verify-mutants-gate below it uses always(), for a reason that gate states: a required check reported as skipped does not block a merge, and !cancelled() skips this job whenever the run is cancelled. Two required checks over the same matrix should not disagree about that. --- .github/workflows/main.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 30b338f9..fcaea124 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -88,7 +88,11 @@ jobs: $LINT_PKGS - name: Trailing newline - if: ${{ !cancelled() }} + # always(), matching verify-mutants-gate below and for the reason stated + # there: a required check that is SKIPPED does not block a merge, and + # !cancelled() skips this job whenever the run is cancelled. always() makes + # it run and report the matrix verdict in that case too. + if: always() run: .ci/check-newline.sh - name: clang-format From 8cb358217c5fe04aeb613f5291d43e5a35337ded Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 14 Aug 2026 10:36:24 +0800 Subject: [PATCH 15/15] Say what the proof matrix cache actually costs The comment claimed the matrix restores the switch the verify job already built, but the commit that folded proving into the matrix deleted that job, and proof-targets runs on Linux so it cannot prime a macOS-keyed cache. On a miss every leg builds Frama-C and the provers from source at once, each against the same 60-minute timeout. That is the cost of bumping any of the three pinned versions, and the first run after such a bump is the one at risk. A prime job would trade it for a barrier in front of every run, so the miss is the cheaper side; the comment says so now instead of describing a job that is gone. --- .github/workflows/main.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fcaea124..4d29e126 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -88,11 +88,7 @@ jobs: $LINT_PKGS - name: Trailing newline - # always(), matching verify-mutants-gate below and for the reason stated - # there: a required check that is SKIPPED does not block a merge, and - # !cancelled() skips this job whenever the run is cancelled. always() makes - # it run and report the matrix verdict in that case too. - if: always() + if: ${{ !cancelled() }} run: .ci/check-newline.sh - name: clang-format @@ -328,8 +324,14 @@ jobs: set -euo pipefail brew install --quiet $BREW_PKGS - # Same cache key as the verify job's opam switch, so this restores the - # switch that job already built instead of rebuilding it nine times. + # Every leg shares one key, so a warm cache costs one restore per leg. + # Nothing primes it any more: the job that used to build the switch first + # is gone, and proof-targets runs on Linux, so on a miss all legs build + # Frama-C and the provers from source at once. That is the whole cost of + # bumping any of the three pinned versions below, and the first run after + # such a bump is the one at risk of the 60-minute timeout. A prime job + # would trade that for a barrier in front of every run; the versions move + # rarely enough that the miss is the cheaper side. - name: Cache opam switch id: opam-cache uses: actions/cache@v6 @@ -406,7 +408,11 @@ jobs: verify: name: Frama-C WP proofs (make verify) needs: verify-mutants - if: ${{ !cancelled() }} + # always(), matching verify-mutants-gate below and for the reason stated + # there: a required check that is SKIPPED does not block a merge, and + # !cancelled() skips this job whenever the run is cancelled. always() makes + # it run and report the matrix verdict in that case too. + if: always() runs-on: ubuntu-latest steps: - name: Report the matrix result