Skip to content

Commit 39634ca

Browse files
authored
Merge pull request #277 from sysprog21/formal
Add machine-checked proofs for critical arithmetic paths
2 parents 8e9275e + 9c77efe commit 39634ca

63 files changed

Lines changed: 4712 additions & 1222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/main.yml

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
# tidy-macos : clang-tidy via `make lint`
77
# verify : Frama-C WP proofs of the attacker-facing arithmetic via
88
# `make verify`; gating, not advisory
9+
# verify-mutants: shows each proof rejects a known-broken source, one job
10+
# per proof target so the 40-mutation set runs as nine
11+
# parallel shards instead of one long serial job
912
# scan-macos : LLVM scan-build via `make analyze`
1013
# infer-macos : Facebook Infer capture + analyze over the full build
1114
# runtime-macos : HVF runtime tests on self-hosted Apple Silicon,
@@ -114,6 +117,15 @@ jobs:
114117
if: ${{ !cancelled() }}
115118
run: python3 scripts/gen-syscall-dispatch.py --output "$RUNNER_TEMP/dispatch.h"
116119

120+
- name: Mutation matrix consistency
121+
# verify-mutants (the Frama-C job below) hand-lists the same proof
122+
# targets mk/analysis.mk defines, sharded one job per target. A
123+
# target present in one but not the other silently drops that
124+
# target's mutation coverage from CI with no error, so catch the
125+
# drift here instead.
126+
if: ${{ !cancelled() }}
127+
run: python3 scripts/check-mutant-matrix-sync.py
128+
117129
# Build verification on macOS Apple Silicon (no HVF runtime tests).
118130
# Hosted runners don't expose Hypervisor.framework, so this job stops at
119131
# `make elfuse` + entitlement check.
@@ -312,6 +324,159 @@ jobs:
312324
path: build/verify-*.log
313325
if-no-files-found: warn
314326

327+
# Shows each Frama-C proof target rejects a known-broken source. Split from
328+
# the "verify" job (needs: verify, so a broken proof fails fast without
329+
# spending nine runners on mutating it) and sharded one job per proof
330+
# target: a caught mutation grinds against every unprovable goal until
331+
# FRAMAC_TIMEOUT, so the 40-mutation set run as one job is minutes where
332+
# "make verify" is seconds. Sharding trades that for nine runners in
333+
# parallel, each bounded by its own target's mutation count instead of the
334+
# whole set's.
335+
#
336+
# The matrix list is VERIFY_<T>_SRC's targets from mk/analysis.mk, hand-kept
337+
# in step: a target missing here silently drops its mutation coverage from
338+
# CI with no error, so add new proof targets to both places.
339+
verify-mutants:
340+
name: Mutation gate (${{ matrix.target }})
341+
needs: verify
342+
runs-on: macos-15
343+
timeout-minutes: 60
344+
strategy:
345+
fail-fast: false
346+
matrix:
347+
target:
348+
- cmsg
349+
- elf
350+
- fuse
351+
- gva
352+
- netlink
353+
- rsp
354+
- sigframe
355+
- sockaddr
356+
- stack
357+
env:
358+
HOMEBREW_NO_INSTALL_CLEANUP: 1
359+
HOMEBREW_NO_AUTO_UPDATE: 1
360+
BREW_PKGS: opam gmp pkg-config graphviz llvm@17 zlib
361+
OPAMCONFIRMLEVEL: unsafe-yes
362+
# Deliberately far below the verify job's 120, and the single biggest
363+
# term in this job's runtime. A mutation is caught by leaving a goal
364+
# open, and an open goal is one both provers spend the whole timeout
365+
# failing to discharge, so every caught mutation costs two full
366+
# timeouts per goal. The verify job wants headroom because one
367+
# [Timeout] there is a false proof regression; here the pressure runs
368+
# the other way.
369+
#
370+
# Cutting it does not weaken the verdict, because check-mutants.py
371+
# proves an UNMUTATED copy of each source through this same path
372+
# first (check_baseline). A value too tight to prove real code fails
373+
# that control loudly instead of silently scoring mutations as caught.
374+
# The margin is wide: all nine targets together discharge 392
375+
# obligations in 31s locally, the slowest single target in 9s.
376+
FRAMAC_TIMEOUT: 45
377+
FRAMAC_VERSION: "31.0"
378+
ALT_ERGO_VERSION: 2.6.3
379+
Z3_VERSION: 4.16.0
380+
OPAMROOT: ${{ github.workspace }}/.opam
381+
OPAM_SWITCH: frama-c-elfuse
382+
steps:
383+
- name: Checkout
384+
uses: actions/checkout@v7
385+
386+
- name: Cache Homebrew downloads
387+
uses: actions/cache@v6
388+
with:
389+
path: ~/Library/Caches/Homebrew/downloads
390+
key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }}
391+
392+
- name: Install Homebrew packages
393+
# shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list.
394+
run: |
395+
set -euo pipefail
396+
brew install --quiet $BREW_PKGS
397+
398+
# Same cache key as the verify job's opam switch, so this restores the
399+
# switch that job already built instead of rebuilding it nine times.
400+
- name: Cache opam switch
401+
id: opam-cache
402+
uses: actions/cache@v6
403+
with:
404+
path: ${{ env.OPAMROOT }}
405+
key: opam-${{ runner.os }}-${{ runner.arch }}-frama-c${{ env.FRAMAC_VERSION }}-ae${{ env.ALT_ERGO_VERSION }}-z3${{ env.Z3_VERSION }}
406+
407+
- name: Install Frama-C, Alt-Ergo, Z3
408+
if: steps.opam-cache.outputs.cache-hit != 'true'
409+
run: |
410+
set -euo pipefail
411+
opam init -y --bare --disable-sandboxing
412+
opam switch create "$OPAM_SWITCH" 4.14.1
413+
eval "$(opam env --switch="$OPAM_SWITCH")"
414+
opam install -y \
415+
frama-c.$FRAMAC_VERSION \
416+
alt-ergo.$ALT_ERGO_VERSION \
417+
z3.$Z3_VERSION
418+
419+
- name: Prove the gate bites (make verify-mutants)
420+
# why3 config detect is required here too: it writes ~/.why3.conf,
421+
# which lives outside OPAMROOT and so is absent on a cache hit even
422+
# though the verify job already ran this once on its own runner.
423+
#
424+
# MUTANT_JOBS is set explicitly for the same reason as before
425+
# sharding: this runner has few enough cores that the script's
426+
# one-per-core default lands near serial.
427+
run: |
428+
set -euo pipefail
429+
eval "$(opam env --switch="$OPAM_SWITCH")"
430+
why3 config detect
431+
# On a pull request, re-verify only the targets whose source the
432+
# branch actually touches; a target it does not touch has the verdict
433+
# the base already established. A push to the base runs the full set,
434+
# so the guarantee is never weaker than the branch it merges into.
435+
# The checkout is shallow, so the base commit has to be fetched
436+
# before it can be diffed against. If that does not work the script
437+
# says so and runs the full set, which is the safe direction for an
438+
# optimization.
439+
base="${{ github.event.pull_request.base.sha }}"
440+
if [ -n "$base" ] && git fetch --no-tags --depth=1 origin "$base"; then
441+
make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }} MUTANT_SINCE="$base"
442+
else
443+
make verify-mutants MUTANT_JOBS=4 MUTANT_TARGET=${{ matrix.target }}
444+
fi
445+
446+
- name: Upload prover log
447+
if: always()
448+
uses: actions/upload-artifact@v7
449+
with:
450+
name: verify-mutants-logs-${{ matrix.target }}
451+
path: build/verify-mutants/*.log
452+
if-no-files-found: warn
453+
retention-days: 7
454+
455+
# One stable check name covering the whole mutation matrix, so branch
456+
# protection has something to require. The matrix leg names carry the target
457+
# in them ("Mutation gate (fuse)"), which means every added proof target
458+
# would otherwise need its own branch-protection entry, and a target added
459+
# without that entry would be unenforced from the day it landed.
460+
#
461+
# needs.<matrix job>.result is the aggregate over all legs, so this passes
462+
# only when every one of them did. if: always() is what makes it run at all
463+
# when a leg fails; without it this job would be skipped, and a skipped
464+
# required check does not block a merge. A skipped matrix (verify failed
465+
# upstream) reports "skipped" here too, which the equality test rejects.
466+
verify-mutants-gate:
467+
name: Mutation gate
468+
needs: verify-mutants
469+
if: always()
470+
runs-on: ubuntu-latest
471+
timeout-minutes: 5
472+
steps:
473+
- name: Require every matrix leg to have passed
474+
run: |
475+
set -euo pipefail
476+
result='${{ needs.verify-mutants.result }}'
477+
echo "mutation matrix: $result"
478+
[ "$result" = success ] || exit 1
479+
315480
# LLVM scan-build via `make analyze`. Runs in parallel with build/tidy.
316481
# Advisory: scan-build's Make target does not pass --status-bugs, so
317482
# findings appear in logs and in the uploaded HTML report but do not

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ $(BUILD_DIR)/test-teardown-live-vcpu-host: \
193193
@echo " LD $@"
194194
$(Q)$(CC) $(CFLAGS) -o $@ $^ $(HVF_LDFLAGS)
195195

196+
## Build the gva-math.h contract-check host test (native macOS binary)
197+
# Header-only: gva-math.h is static inline, so the test links nothing from the
198+
# project. It skips unless the build defines ELFUSE_CONTRACT_ASSERT, which is
199+
# what "make check-contracts" does.
200+
$(BUILD_DIR)/test-gva-contracts: $(BUILD_DIR)/test-gva-contracts.o \
201+
| $(BUILD_DIR)
202+
@echo " LD $@"
203+
$(Q)$(CC) $(CFLAGS) -o $@ $^
204+
196205
## Build the volume naming probe (native macOS binary)
197206
# Standalone: it measures the filesystem, so it links nothing from the project.
198207
$(BUILD_DIR)/probe-volume-naming: $(BUILD_DIR)/probe-volume-naming.o \

0 commit comments

Comments
 (0)