diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d549fa93..4d29e126 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' + - name: Read the targets from mk/analysis.mk + id: list 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". - 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 @@ -395,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 @@ -416,10 +351,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 +393,38 @@ 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 + # 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 + 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/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/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 3da6e0dd..fb007911 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 + check-stub-constants print-verify-targets infer-uninit CLANG_TIDY ?= clang-tidy INFER ?= infer @@ -13,14 +11,28 @@ 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 \ --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 +50,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 +60,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 @@ -86,6 +98,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. @@ -93,8 +114,37 @@ FRAMAC_TIMEOUT ?= 30 # 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 @@ -124,13 +174,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 +210,137 @@ 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_FCTS := netlink_align_up netlink_rta_bounds netlink_msg_span -VERIFY_NETLINK_MIN_GOALS ?= 44 +VERIFY_NETLINK_SRC := src/proved/netlink.h +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/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_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 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 +353,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 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; +# 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 +408,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): 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"; \ @@ -361,7 +428,7 @@ verify-elf verify-rsp verify-gva verify-cmsg verify-fuse verify-stack \ -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) \ @@ -409,11 +476,41 @@ check-char-signedness: @echo " CHARSIGN proof sources under both char signedness settings" $(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 +# 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) -## Rebuild with the gva-math.h precondition checks live, then run the suite +## Run every Frama-C proof +# +# 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. +## 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) + +## 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 +522,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 +554,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..da04c67b --- /dev/null +++ b/scripts/analysis-mk.py @@ -0,0 +1,48 @@ +"""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-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-char-signedness.py b/scripts/check-char-signedness.py index f683c6ef..fd3cdcaa 100755 --- a/scripts/check-char-signedness.py +++ b/scripts/check-char-signedness.py @@ -62,10 +62,12 @@ 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..f91210a6 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,16 +404,57 @@ ), ( "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);", " 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-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", - "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 +462,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 +470,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 +478,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 +486,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 +494,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 +517,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 +617,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 +905,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 +1003,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 +1026,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 @@ -570,11 +1045,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, @@ -616,7 +1134,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" @@ -756,11 +1282,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 +1330,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/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()) diff --git a/src/core/guest.c b/src/core/guest.c index 4904429c..350c45b7 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" @@ -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/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 66% rename from src/syscall/netlink-math.h rename to src/proved/netlink.h index 1ab753c6..5ecd9aeb 100644 --- a/src/syscall/netlink-math.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 @@ -64,7 +66,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; @@ -155,3 +157,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/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-internal.h b/src/runtime/procemu-internal.h new file mode 100644 index 00000000..ba952dd0 --- /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. 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. */ +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..94bacd81 --- /dev/null +++ b/src/runtime/procemu-pty.c @@ -0,0 +1,1682 @@ +/* + * 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, 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 + +/* 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; +} + +/* 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); + 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 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. + */ + 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; + 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. + */ + 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; +} + +/* Lock-acquiring convenience wrapper used by the open-time and fork-restore + * paths where atomicity with fd_table is not required. + * + * 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, + 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); + return rc; +} + +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. + * 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; + 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].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 + * 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; + + /* 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( + "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; + } +} + +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. + */ + 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) +{ + 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; + + /* 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); +} + +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. + */ + 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) +{ + /* 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(); + 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 + * 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 { + /* 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 + * 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; + } + + /* 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)) { + 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; 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) + 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 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') + 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; + + /* 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; + } + 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 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; + } 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. + * + * 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. + */ + 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; + +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; + } + + /* 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. + */ + 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; + 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 (reg == PTY_REG_EXISTS) + close(slave); + return master; +} diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index 0acb4aef..6023786d 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,15 @@ #include #include #include -#include -#include #include "string-builder.h" #include "utils.h" +#include "proved/slice.h" + #include "debug/log.h" #include "runtime/procemu.h" +#include "runtime/procemu-internal.h" #include "core/rosetta.h" #include "runtime/thread.h" @@ -381,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; } @@ -450,7 +449,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 +482,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 +516,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 +565,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 +1225,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 +1694,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 +2168,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 +3455,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,21 +3483,21 @@ int proc_intercept_readv(int guest_fd, char text[32]; int len = proc_oom_format_value(kind, text, sizeof(text)); - size_t src_len = (size_t) len; - if ((uint64_t) offset >= src_len) { - *read_out = 0; - return 1; + if (len < 0) { + errno = EIO; + 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/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 e6d61663..4597a54a 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) { @@ -723,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. - */ - 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. + /* 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_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 0a8c6254..45975396 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" @@ -178,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; @@ -546,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(); } @@ -556,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(); } @@ -752,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(); @@ -965,6 +983,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. @@ -990,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; } @@ -1628,12 +1646,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 +1705,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 +1728,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); @@ -1814,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/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..ac13e04a 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 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 + * 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. + * + * 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) + 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..522bf178 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" @@ -215,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) { @@ -286,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, @@ -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 */ } @@ -875,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) @@ -944,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, @@ -1017,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; @@ -1028,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) @@ -1075,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; @@ -1151,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) @@ -1166,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, @@ -1242,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 " @@ -1255,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 @@ -1623,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) @@ -1689,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) @@ -2277,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. @@ -2445,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) { @@ -2462,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; @@ -2480,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; } @@ -3386,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 = @@ -3421,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. */ @@ -3430,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). */ @@ -3538,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) { @@ -4061,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); @@ -4334,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) 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..d8f3a311 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,17 @@ 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 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 = 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); diff --git a/src/syscall/netlink.c b/src/syscall/netlink.c index ec3d8bfc..81deddd8 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. */ @@ -176,24 +176,41 @@ 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, 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 @@ -483,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, @@ -505,11 +533,21 @@ 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)); - /* 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. */ @@ -519,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]; @@ -720,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 @@ -731,10 +788,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..abe7f56e 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; @@ -855,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/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/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; } 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 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; }