Skip to content

Widen the proved arithmetic - #291

Merged
jserv merged 15 commits into
mainfrom
formal
Aug 14, 2026
Merged

Widen the proved arithmetic#291
jserv merged 15 commits into
mainfrom
formal

Conversation

@jserv

@jserv jserv commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Nine proof targets read their sources from files named foo-math.h, each sitting beside the .c it had been split out of. The suffix described how the file came to exist rather than what it is for, and the scattering left no way to ask what the tree actually proves. They move to src/proved/.h, and check-proof-targets.py enforces membership in both directions: a header there that no target proves, and a target naming a file git does not track.

Seven targets join them (align, dirent, fdset, iov, pathdepth, slice, timespec), taking the proved surface to 590 obligations over 37 functions, with 88 mutations showing those contracts constrain something rather than merely parse.

Writing the contracts found five defects the tests did not. pselect6 honored fd_set bits above nfds, which Linux ignores, so it polled descriptors the caller never named and returned EBADF when one was closed; it also bounded nfds by the host FD_SETSIZE rather than the fd table size, two constants that are both 1024 on macOS and are not the same constant. ppoll, pselect6, epoll_pwait2 and recvmmsg truncated a sub-millisecond timeout to poll(0), turning a 500 microsecond wait into a spin. epoll_pwait2 and recvmmsg converted a large finite timeout into an infinite one. linux_timespec_to_ns_sat overflowed signed 64-bit arithmetic on its way to saturating.

Two splits keep the surrounding code readable enough to check against those contracts: the pty side table leaves procemu.c for procemu-pty.c, and five HVC cases leave vcpu_run_loop_with_hooks for named handlers, halving it.


Summary by cubic

Widens and centralizes machine-checked arithmetic under src/proved/, and splits two risky hotspots (pty keepalive table; five HVC cases) to cut risk. Behavior changes: pselect6 now masks bits above nfds and bounds nfds by the fd table; ppoll/pselect6/epoll_pwait2/recvmmsg round sub‑ms waits up, clamp huge finite waits, and reject negatives with EINVAL; recvmmsg no longer parks indefinitely; signed overflow is removed from timespec math; align_up_ok admits valid top‑of‑address‑space candidates; closing one master alias no longer hangs up a live slave; dirfd‑ and cwd‑relative /dev/pts paths re‑derive through the intercept; netlink attribute writes validate 16‑bit lengths and buffer space.

  • Proved surface and CI

    • Moves proved headers to src/proved/ and adds align, dirent, fdset, iov, pathdepth, slice, timespec; adds netlink walk proofs; ties bounds to implementation via static_asserts (fd table size, NAME_MAX, time scale).
    • Adds scripts/analysis-mk.py; CI’s verify-mutants matrix derives from “make print-verify-targets”; verify and verify-mutants-gate aggregate the matrix; runs parallelize and mutations scope to the changed function with a safe whole‑target fallback.
    • Adds Frama‑C stubs for Hypervisor, GCC atomics, and Darwin constants; gates stub constants against the macOS SDK; workflow reports matrix verdicts even on cancelled runs.
  • System calls and runtime

    • Waits: ppoll/pselect6/epoll_pwait2/recvmmsg share a saturating, rounding conversion; epoll_pwait2 reaches the “indefinite” path only at/above the cutoff.
    • fdset: pselect6 sizes/masks bitmaps from proved word counts; static_asserts bind proved bounds to the fd table.
    • Memory/paths: align_up_ok guards mmap gap search; proved dirent/iov arithmetic replaces ad‑hoc sums; proved slice clamps read windows; proved path_depth_pop floors at 0.
    • /dev/pts and ptys: pty side table moves to procemu‑pty.c with one accounting home per pty across master aliases; reused‑row slave counts replay into a newly attached segment; proc_pty_forget_host_fd drops stray fds from both tables; dirfd‑ and cwd‑relative /dev/pts paths re‑derive through the intercept.
    • Netlink: fixes nl_put_attr’s length wrap and proves walk loops. New tests cover sub‑ms ppoll and pty alias/dirfd/cwd paths.

Written for commit 8cb3582. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the formal branch 2 times, most recently from e66c861 to 8a14d43 Compare August 14, 2026 01:29
jserv added 15 commits August 14, 2026 11:29
procemu.c had grown to 4759 lines carrying three interceptors and a pty
side table that shares nothing with them: /proc, /sys and /dev synthesis
on one side, and on the other a keepalive table, a shared segment, slave
accounting and the /dev/pts staging directory.

The pty half moves out whole. procemu-internal.h carries the eight
declarations the two halves still exchange, and the scratch-directory
registry gains a named entry point so the pty side reaches it without
the five statics behind it.
The loop every syscall passes through ran to 1178 lines, which makes it
simultaneously the riskiest function in the tree to modify and the least
readable. Five of its cases handle an exception and are otherwise
self-contained: the MRS trap, the system-instruction trap, the W^X
toggle, BRK from EL0, and the bad-exception halt.
Nine proof targets read their sources from files named foo-math.h, each
sitting beside the .c it had been split out of. The suffix described how
the file came to exist rather than what it is for, and the scattering
left no way to ask what the tree actually proves. They move to
src/proved/<name>.h, and check-proof-targets.py enforces membership in
both directions: a header there that no target proves, and a target
naming a file git does not track.

Seven targets join them (align, dirent, fdset, iov, pathdepth, slice,
timespec), taking the proved surface to 590 obligations over 37
functions, with 88 mutations showing those contracts constrain something
rather than merely parse.

Writing the contracts found four defects the tests did not. pselect6
honored fd_set bits above nfds, which Linux ignores, so it polled
descriptors the caller never named and returned EBADF when one was
closed; it also bounded nfds by the host FD_SETSIZE rather than the fd
table size, two constants that are both 1024 on macOS and are not the
same constant. ppoll and pselect6 truncated a sub-millisecond timeout to
poll(0), turning a 500 microsecond wait into a spin.
linux_timespec_to_ns_sat overflowed signed 64-bit arithmetic on its way
to saturating.

align_up_ok rejects only what it must. Padding by align - 1 before
testing for overflow refuses an address that is already a multiple and
lies within align - 1 of the top of the address space, where rounding up
is a no-op that cannot carry. Counting in multiples is exact, and one
syntactic k * align hands the alignment postcondition its witness;
reaching the same value by adding align to a floor computed earlier
needs distributivity the provers do not apply.

The proved helpers are used where the bound is a fact about the input,
not a restatement of what the caller already established. Guarding
fdtable.c's one-line bitmap setters that way put the check on the
harmless write, since fd_init_entry indexes fd_table with the same fd on
the next line and cannot be guarded from inside a bitmap setter; an
analyzer reading the pair then has to assume the table write can land
anywhere. Those setters index directly, and fdset.h states the
distinction so the next call site does not have to rediscover it.

The proof and mutation gates stop keeping a second copy of the target
list. mk/analysis.mk derives it from its own VERIFY_<T>_SRC variables,
scripts/analysis-mk.py is the one reader of those, and the CI matrix is
fromJson of "make print-verify-targets" rather than a hand-kept list
that silently dropped a target's coverage when it fell out of step.
recvmmsg and epoll_pwait2 shared syscall_timeout_ms_or_forever, which
maps an effectively-infinite request onto poll's -1. That mapping earns
its keep in epoll_pwait2, where a negative timeout selects the 200 ms
re-arm loop that re-checks exit_group, pending signals, futex interrupts
and pty hangup. recvmmsg has no such loop: it waits in a single poll, so
-1 parked it there with nothing to wake it, on a call the guest had
asked to bound. It takes the saturating conversion now, which is still
far past any real timeout but is actually reached.
pty_keepalive_table holds a row per master host fd, but three of the
fields on it describe the pty rather than the master: the guest slave
count, the seen flag, and the shared segment. Aliased masters get a row
each -- dup, dup2, F_DUPFD, an SCM_RIGHTS adopt, a fork restore -- while
the counting paths took "the first row in table order for this minor".
So a count could be recorded against one row and handed back to the
segment by another row's destruction.

Closing one alias of a master then reported a hangup with the slave
still open: poll returned POLLHUP and read returned EIO where Linux
gives EAGAIN. Ordering the rows the other way lost the hangup instead,
because the release found a fresh alias at a lower index holding no
count and stopped there. A slave open that failed after the intercept
had already recorded it left the count behind for good, and the master
could never hang up again; O_DIRECTORY on a slave path reaches that,
since the host open succeeds and only the type check rejects it.

Every counting path now resolves the same accounting home for a minor:
the row already holding a count, else one that has seen a slave, else
the first. Ranking a row that has seen a slave above one that has not is
what stops a new alias at a lower index from displacing a home whose
count has just fallen to zero. A master close hands its count to another
row for the same pty, and keeps the row as a master-less home when there
is none. An heir mapping no segment cannot take a count that was
contributed to one; in the other direction the segment learns about the
count as it arrives. Reusing a row for the minor it already describes
keeps its accounting rather than detaching it, except after a fresh host
allocation, where the minor only returns once every fd on it is gone.

The row is released once its last counted slave closes, which is what
gives back the retained slave fd that pins the macOS tty. Nothing did
before, so the pin outlived every use of it. That is worse than a leak:
it keeps the tty half-alive after the master is torn down, and a slave
open against it blocks rather than failing, so a child consulting its
own pts path after the pty is gone waits forever. One case this narrows
deliberately -- a child that closes every slave it holds and then
reopens the path gets ENOENT, where Linux would allow it while some
process still holds the master. The child cannot see that master, so its
own count is the only signal it has.

Two lifetime bugs sat alongside. pty_keepalive_register reported through
errno, which its insert path cannot do honestly: pty_shared_attach joins
an existing segment by letting an O_EXCL create fail with EEXIST and
reopening, and nothing clears errno after, so a successful insert during
fork restore came back as EEXIST and the caller closed an fd the table
had already recorded. The number was then reused, and the guest's next
open of its slave either failed or aliased an unrelated file. It returns
the PTY_REG_* status instead. Separately, pty_open_slave consumed a
fork-child's one-shot mapping whether the open succeeded or failed, so a
child that reopened its slave, stat'd its own pts path, or read
/dev/pts after the first open got ENOENT while still holding a live
slave on that pty. The mapping is given up only on failure now.

/dev/pts is served from a staging directory of placeholder files so
getdents64 lists the right names, and the open and stat intercepts key
on an absolute path, so anything measured against a descriptor for it
reached a placeholder: fstatat reported a 0444 regular file rather than
a character device, and openat returned that file rather than the slave.
Stamping the guest spelling puts relative calls back through the
intercept, for a directory fd and for a cwd alike, which is where the
pty semantics and the accounting live.

proc_pty_forget_host_fd replaces the four places that hand-paired the
master and slave cleanups. A host fd that never becomes a guest fd has
to leave both tables, the call site cannot tell which one holds it, and
calling one alone is silent either way: a leaked keepalive slave, or a
phantom count that suppresses the hangup for good.
make verify ran its sixteen targets one after another although each is a
separate frama-c process writing its own log and sharing nothing. Only
invocations that remembered -j got the parallelism. It recurses with -j
itself now, and adds one only when the caller brought none: a forced -j
in a submake makes it drop the inherited jobserver and start that many
processes whatever the outer limit was. 46.6s to 14.4s here. Make
records no -j for -j1, so that spelling still parallelizes under the
3.81 macOS ships; VERIFY_JOBS=1 is the way to ask for serial under
either.

Each mutation re-proved every function in its target when WP proves each
function against its callees' contracts rather than their bodies, so
editing a body can only move that one function's goals. Mutations prove
just the function they name. Editing a contract does move its callers'
goals, and no entry names the caller, so those stay at full scope; ACSL
living in comments is what makes them detectable.

The narrowing is arranged so it cannot change a verdict rather than
merely being argued not to. A mutation the narrow run fails to catch is
re-run over the whole target before it may read as MISSED, so the fast
path carries the common case and a wrong scope costs time instead of
coverage. Misdirecting a mutation's scope on purpose still reports it
caught. 3m36s to 2m16s, all 88 still caught.
Frama-C has no Apple SDK, so every source reaching guest.h or thread.h
stopped at a missing Hypervisor header before parsing began, and its
portable libc omits both the type-generic __atomic_*_n builtins and a
few Darwin constants the tree calls. An absent builtin is worse than an
absent header: the call parses as an implicit declaration whose argument
types are inferred per translation unit, so each file loads on its own
and the project refuses the moment two of them infer different widths
for the same name.

frama-c-stubs declares what is missing and nothing else. gcc-atomics.h
pulls in Frama-C's own __fc_gcc_builtins.h for the builtins it does
model and stdatomic.h for the _Atomic qualifier, where Frama-C states
the qualifier is ignored, so that concession is the analyzer's rather
than one invented here; it adds only the generics with no entry at all.
Values in the stubs are the real ones where the tree could observe them.

Outside src/ deliberately: a compile resolves headers through -Isrc and
would find a stub shadowing the SDK header the binary must link against.
Only FRAMAC_STUB_DIR reaches them. A name none of them declares fails
with "Cannot resolve variable", which is how the next one gets found.
frama-c-stubs/macos-libc.h says its values are Darwin's real ones so
they cannot collide with another arm of a switch the proofs walk. That
claim was wrong the day it was written: ETOOMANYREFS was given 62, which
is Darwin's ELOOP, and both are arms of the same linux_errno switch.

Nothing caught it because nothing was checking, and a build never will.
The analyzer does not link, so a wrong value cannot fail one; it changes
what the proofs reason about instead. Two arms sharing a value makes one
look unreachable, and a proof over that switch is then about a program
nobody ships. That is the shape of error this tree already gates with a
script rather than a comment.

Hung off $(VERIFY_RULES) rather than the top-level verify target, which
is what makes it run at all: CI invokes "make verify-<target>" per
matrix leg and "make verify-mutants", never bare "make verify". As a
phony prerequisite shared by every rule it still executes once per
invocation, so proving one target pays for one SDK query.
The extent was computed as "(uint16_t) (RTA_HDRLEN + datalen)" and only
the aligned result was checked against the space left. For datalen at or
above 65532 that cast wraps: the total comes back as 3 or less, the
aligned value as 4, the space check passes, and the payload memcpy then
writes up to 65535 bytes into a buffer with 4 bytes free.

Nothing reaches it. The five call sites pass 4, 16, or an interface name
from getifaddrs. That is unreachable by caller provenance, which is the
argument netlink_msg_span directly above it was written to stop relying
on, so this gets the same treatment rather than a comment.

netlink_attr_extent states that a non-zero result means the total is
RTA_HDRLEN + datalen, fits the 16-bit wire field, and that the aligned
extent fits the buffer. The caller's remaining cast to rta_len is then
lossless exactly when it returned non-zero, and both writes land inside
max. Both halves of the guard carry weight and both are mutation
covered: dropping the wire-field ceiling reproduces the wrap, dropping
the space check runs the payload past the buffer.
netlink.h proved the rtattr arithmetic and its UNPROVED line said the
walks around it stayed test-covered. Both can be proved now that
netlink.c parses.

nl_parse_link_filter reads an rtattr chain out of bytes the guest wrote;
nl_complete_span walks the reply buffer. Their contracts state what the
callers already guarantee, and the loop annotations state what the walk
maintains: for the parse, that off stays bounded by reqlen so the C loop
test cannot wrap, and that the name copy leaves room for its terminator.
nl_put_attr's payload precondition restates memcpy's own predicate from
Frama-C's string.h rather than a hand-written \valid_read, because
datalen may be 0 and an empty range says nothing about the pointer while
memcpy still demands \object_pointer.

The memory model is Bytes, neither of the two already here. Both walks
memcpy a wire header out of a uint8_t buffer at an offset the message
chose, so typed leaves every memcpy validity and separation goal open at
58 of 74, and caveat cannot size the byte array at all. Typed+cast
reaches 73 of 74.

The four helpers are re-proved rather than assumed, for the reason
VERIFY_UTILS_FCTS exists. 198 obligations, and three mutations covering
what the annotations are there for: the terminator's room, the
fits-the-copy guard, and the padding extent.

nl_parse_link_filter joins CHAR_PARAM_ALLOWLIST. Its char * is an output
buffer it only writes, fed from a uint8_t source through an explicit
cast, so it reads no plain char and the gcc_x86_64 signedness cannot
reach it. The compiler-based check agrees for all seven.
mem.c, guest.c and rosetta.c used va_start and va_end as local variable
and parameter names for the ends of an address range. They shadow the
stdarg macros. It compiles, since both are function-like and a
declaration never invokes them, but procemu.c two directories away calls
the real ones, and Frama-C rejects the file outright under CERT MSC38-C,
which is what kept mem.c, the largest source in the tree, out of the
analyzer.

va_base and va_limit instead. rosetta.c already used va_base for the
start of exactly this kind of range, and limit is the word the tree uses
for an exclusive upper bound in mmap_limit and backing_limit. The four
genuine va_start(ap, ...) call sites are untouched.
pty_keepalive_register_locked keeps guest_slave_count when it reuses a
row for the minor that row already describes, because those slaves are
still open. If the row had no shared segment then, the count was
accumulated against nothing, and the segment it joins on this
registration knows nothing about it. Each of those slaves decrements on
close a total it was never added to, the shared count goes negative, and
pty_slot_hung_up_locked reads that as a hangup with the slaves still
open, which is the failure the row rework set out to remove.

Reaching it needs pty_shared_attach to have returned NULL once, the
master to close leaving the row as the pty's master-less home, and a
dup, an SCM_RIGHTS adopt or a fork restore for the same minor to
re-register it while the attach succeeds.

The compensation is the one pty_keepalive_clear_slot_locked already
makes when it hands a segment-less count to an heir that maps one, and
it is guarded to the same-pty path, since every other route zeroed the
count just above.
The comment over them in procemu-internal.h said four and listed five.
verify used "if: ${{ !cancelled() }}" where verify-mutants-gate below it
uses always(), for a reason that gate states: a required check reported
as skipped does not block a merge, and !cancelled() skips this job
whenever the run is cancelled. Two required checks over the same matrix
should not disagree about that.
The comment claimed the matrix restores the switch the verify job
already built, but the commit that folded proving into the matrix
deleted that job, and proof-targets runs on Linux so it cannot prime a
macOS-keyed cache. On a miss every leg builds Frama-C and the provers
from source at once, each against the same 60-minute timeout. That is
the cost of bumping any of the three pinned versions, and the first run
after such a bump is the one at risk. A prime job would trade it for a
barrier in front of every run, so the miss is the cheaper side; the
comment says so now instead of describing a job that is gone.
@jserv
jserv merged commit 42a4099 into main Aug 14, 2026
30 checks passed
@jserv
jserv deleted the formal branch August 14, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant