Skip to content

Latest commit

 

History

History
1344 lines (1154 loc) · 99.6 KB

File metadata and controls

1344 lines (1154 loc) · 99.6 KB

Testing

KASLD has seven test layers, in increasing order of setup cost:

  1. Host unit + integration tests + static guards — pure C over synthetic evidence, plus grep/shellcheck source-invariant guards (make lint). No deps beyond a C compiler. This is the primary safety net.
  2. End-to-end replay — runs the real kasld binary over captured filesystem trees. Native (no qemu) for the host arch; qemu-user for foreign arches.
  3. Cross-arch engine tests — the unit tests run on each architecture under qemu-user, so arch-gated rule bodies execute their real path.
  4. Coverage reports — optional, gcov-based.
  5. Live cross-architecture validation (tests/vm/run) — boots real publicly-fetchable kernels under qemu-system and checks the inferred range contains the live kernel's true base, across arches and privilege profiles.
  6. Parser fuzz harnesses (tests/fuzz/) — libFuzzer harnesses for the pure string→struct parsers that read component output (src/capture.c, src/meta.c, src/orchestrator.c), the BTF binary parser, and the report model. Opt-in (make fuzz), not part of CI.
  7. Container / cgroup execution (make test-container) — runs kasld under a masked /proc, a seccomp filter, and cpu/memory/pids caps. Opt-in; snapshots the live host, so it is not part of the hermetic make test.

Quick start (everything that needs no cross toolchain or qemu):

make check          # build + run the full host unit/integration suite
KASLD_NATIVE=1 tests/replay tests/fixtures/x86_64/* tests/fixtures/x86_32/*

Table of Contents


1. Host unit + integration tests

make check          # runs `make test` then prints "OK: host test suite passed."
make test           # build + run all test drivers (49), then the lint guards
make lint           # just the static guards (no test-binary build)

Each driver is a standalone binary in build/tests/. Test binaries (this layer) and fuzz harnesses (layer 6 below) live in build/tests/ and build/fuzz/ respectively — both are siblings of the per-arch deploy tree build/<arch>/, so neither is reachable by make install (which installs the kasld binary, its component binaries, ksymoff, the man pages and the docs — never the sibling test or fuzz trees).

The table below is a representative subset; the authoritative list of drivers that make test builds and runs is TEST_ALL_BINS in the Makefile.

Driver Covers Links
test_estimate lattice meet, bottom test, the greedy priority resolver estimate.c + quantities.c
test_evidence observation store + verdict application evidence.c
test_engine every rule in src/rules/ over synthetic evidence engine core + all rules
test_engine_integration the full production rule registry against leak-bearing evidence engine core + engine_rules.c + all rules
test_kasld orchestrator internals (parse, merge, anchor select), the engine→layout projection, the environment gatherer, region_info orchestrator.c / capture.c / discard.c / meta.c / environment.c / region_info.c under -DKASLD_TESTING
test_render the renderers (text / json / markdown / oneline / hardening / map) render.c / render/*.c under -DKASLD_TESTING
test_align the text-base floor helpers (kasld_floor_aligned_suboffset / kasld_floor_text_base) api.h (header-only)
test_text_order the kernel-text ordering classifier (classify_text_order) text_order.h (header-only)
test_dmesg_layout the riscv print_vm_layout dump parser components/dmesg_mem_init_kernel_layout.c (#included, main renamed)
test_btf the BTF struct-size reader behind btf_facts components/btf_facts.c (#included, main renamed)

After the drivers, make test runs tests/check-render-width: it renders the built kasld binary and asserts every line of the output it lays out stays within 110 columns. Width is per-architecture — address columns and candidate counts are wider on 64-bit targets — so an overflow introduced on one layout is invisible on the build host until someone renders that target. Each binary runs against an empty sysroot, which leaves every window at its architectural default and so produces the widest output the tool can emit.

It measures the host binary only, to stay inside the fast run. Set KASLD_WIDTH_ALL=1 to sweep every built target under qemu-user (tens of seconds; targets whose interpreter is absent are skipped). --verbose is not measured: its bulk is component diagnostics and echoed kernel log text, strings chosen for what they say rather than how wide they are. The target-identity line is exempt for the same reason — it interpolates an unbounded kernel version string.

Every test binary also carries the hermeticity probe. Test builds define KASLD_HERMETIC_PROBE, under which kasld_resolve records any kernel fact path resolved while KASLD_SYSROOT is unset — a read that went to the machine running the test rather than to a tree the test supplied — and the harness fails that binary at its tally, listing the paths:

7/7 tests passed
read 1 kernel fact path from the host:
  /proc/version
Stage a tree and point KASLD_SYSROOT at it before the first read.

A test that reads the host asserts against whatever that machine holds, or against what it happens to lack, which the test's own text does not reveal. The check is a runtime one because a source scan cannot see it: the read is normally several frames below the test, so a renderer test that names no path still reaches container detection, the LSM probe and the group database. It equally catches staging done too late, since the prefix is resolved once and cached and a read before the setenv resolves live.

The fix is to supply the source rather than borrow it: stage a directory, write the files the test needs under it, and set KASLD_SYSROOT to it before the first read. An empty staged tree is a legitimate answer, and the correct one where the test wants the source absent. The probe is never defined for a shipped build.

Run one driver in isolation:

make test-estimate
make test-evidence
make test-engine
make test-integration
make test-dmesg-layout
make test-btf

test_kasld is built with -DKASLD_TESTING, which compiles out main(), the engine_build_evidence bridge, and the live engine run. Those — the real collect → bridge → resolve → render path — are exercised only by replay (layer 2).

Compiler / flags: make test CC=clang, CFLAGS=... as usual. pthread is used when available (HAVE_PTHREAD), matching the normal build.

Static guards (make lint)

make test finishes by running make lint — guards that assert source invariants the unit tests can't. Most are pure text over src/, so they need no build and run in a second; five are not, and it matters when the tree must stay frozen: check-truncation compiles a translation unit for i686, check-hash-parity builds tests/check_hash_parity.c, and check-baseline, check-render-parity and check-render-color execute already-built binaries. Run them alone with make lint (fast; no driver build).

The guards are independent, so they run several at a time — JOBS sets how many, one per core by default, and JOBS=1 runs them one at a time. Each one's output is printed in the order the lint target lists them rather than the order they finish, so the transcript does not depend on the scheduling. Each guard exits non-zero on failure; every guard runs even after one fails, and lint itself exits non-zero if any did.

A guard colours its summary when writing to a terminal. The runner captures each guard's output to replay it in order, so it passes that decision down in KASLD_COLOR: a sweep run at a terminal stays coloured, one redirected or piped stays plain, and setting KASLD_COLOR non-empty or empty forces either.

Guard Asserts
check-rule-registry every src/rules/*.c is registered exactly once in engine_rules.c (an unregistered rule compiles but never runs) and is exercised by a dedicated test — by name in test_engine.c, or via the integration-tested allowlist
check-test-checks no test check is written with assert(), which the preprocessor removes wherever NDEBUG is defined — a suite of removable checks reports a full pass on a broken tree, and NDEBUG reaches the tests through CFLAGS. Checks use TH_CHECK
check-render-registry every src/render/*.c is compiled into both renderer test binaries — the Makefile globs that directory while test_kasld.c and test_render.c name each file, so a renderer missing from a list still ships but contributes no coverage
check-self-edges no engine rule reads est[Q] and writes Q (a "self-edge") outside the reviewed allowlist — each such rule needs a soundness test
check-extent-callers only reviewed whole-map components call kasld_result_extent (the covering-completeness contract; a partial map would carve a false gap)
check-discard-accounting the shipped binary counts discards exactly with its worker pool running — N components × M bad wire records must total N×M in N kinds †
check-discard-report the ledger's two renderings (--verbose and -j) agree with each other and with a store actually driven past its caps †
check-scalar-seed-order the arch's compile-time KASLR-off facts are seeded into scalar_facts[] before the phase loop, and only capture_scalar() and seed_arch_kaslr_facts() append to it †
check-vantage-coverage every filesystem source kasld_gather_vantage() reads is staged by a test, the suite actually calls the gatherer, and the absent direction is asserted †
check-test-staging every test binary stages its filesystem through test_sysroot.h, which names the root after the binary and registers its own removal †
check-discard-ledger every reason in the discard vocabulary has a wire name, no layer keeps a private drop-counter beside the ledger, and the renderers read it through its accessors †
check-covering-consumers every rule reading ev->coverings[] is reviewed and calls covering_active() first — the read end of the same contract; the floor gate demotes a below-floor map by clearing its valid bit, and a rule that never asks carries it into the guaranteed window regardless of what it emits
check-truncation no silent 64-bit→word narrowing when compiled for 32-bit (compiles a TU with i686-linux-gnu-gcc)
check-addr-parse kernel addresses are converted with kasld_addr_parse outside a reviewed allowlist — sscanf("%lx") reports success on an address wider than the word and hands back a truncated one
check-hostname-scrub anonymizing a capture replaces the hostname only where it stands as a whole name — matched as a bare substring, a host called debian rewrites the distro build address in /proc/version
check-absence-vs-denial no component reports a denied source as an absent one — a failed probe's reason is in errno, and UNAVAILABLE claims the target's build while NOPERM reports its hardening
check-component-output components write only wire lines to stdout (stdout is the machine channel; diagnostics go to stderr)
check-component-announces every component accounts for itself. A blank section under a component header says neither "not applicable here" nor "refused" nor "found nothing", and those are different facts about the target. Two assertions: every source calls kasld_info at least once, and every component still writes something when run against a sysroot holding nothing — the second is what catches an announce placed after the early returns, which reads as correct in source and goes silent the moment a source is missing. Live probes skip under a sysroot and are held to the static assertion alone
check-component-meta every component declares KASLD_META with the mandatory method:, discloses: and source: keys, each carrying a value from its closed vocabulary, and both api.h and CONTRIBUTING.md document exactly that vocabulary
check-component-tests every method:parsed component is #included by a test translation unit, or carries a reviewed entry on the guard's own allowlist — so the component layer cannot grow a parser no test exercises
check-component-sections every built component binary carries .kasld_meta and .kasld_explain, present and non-empty. KASLD_META/KASLD_EXPLAIN place their text in ELF sections via __attribute__((used, section(...))), and the orchestrator reads them back out of the binary to assign phases, print --explain, and build the hardening report. check-component-meta holds the declaration side — that every source declares one, with a vocabulary agreeing across the tree — but it reads source and cannot see whether the declaration reached the binary. Renaming the section in the attribute leaves every source declaring metadata, every other guard passing, and --explain producing nothing; a link flag discarding unreferenced sections does the same without touching a line of source. Every triple built is checked, not only the host's: the attribute is architecture-independent but the link is not, and readelf reads a foreign ELF as readily as a native one. That also makes a stripped build checkable, which is what lets a release claim stripping is safe — the sections either survived or this fails. A zero-size section counts as absent, reading identically at runtime
check-components-built every component source produced a build artefact. The component recipe exits 0 whatever the compiler said, deliberately: with 120 independent leaf targets, one that will not compile must not stop the other 119 being built and tested. It removes the target instead, so a broken component becomes absent rather than stale and is never silently exercised as the last binary that happened to compile. That left the failure for something else to notice, and nothing did — the orchestrator finds components by scanning the directory, so an absent one is simply a smaller set reported as success, and no guard compared the build against the source. A component could stop compiling with make, make test and make cross all staying green. The recipe already writes the distinction to disk, so this reads it rather than tracking its own: a non-empty file compiled, an empty file is the architecture-gated skip path writing a deliberate empty target, and an absent file is a compiler failure. No expected count is used, and none may be — a fixed number rots the moment a component is added, which is this same failure one level up. A build given a COMPONENTS selection records it, and the question is then asked of that set; the source directory still sets the scope, so a narrow selection stays distinguishable from a glob that stopped matching
check-component-cap MAX_COMPONENTS keeps a margin above the in-tree component count — a component directory that overruns it silently drops the excess
check-log-prefixes no diagnostic message begins with a [.]/[-]/[+] marker (the kasld_info/kasld_err/kasld_found helper already prepends one — an embedded marker doubles it)
check-live-probes every component's declarations agree with its code. source: — a files component contains no live primitive, and a live or hybrid one carries the self-guard that keeps it from running offline against the analysis host. status:experimental — it pairs with kasld_skip_experimental(), in both directions, so a component the orchestrator holds back cannot run freely when invoked directly
check-fact-source "where do this run's facts come from" is asked only through kasld_fact_source(); kasld_sysroot() and the environment variable belong to sysroot.h
check-host-build "which build directory is this host's" is asked only through tests/lib/host-build.sh. The build system keeps two triples apart — a native compiler's, and $(CC)'s, which names the directory — and a harness that resolves it by hand finds nothing when a build was made with a non-default CC, then skips while reporting success
check-clang-build a compiler whose assembler is not GNU as builds every component too. The tree is built with gcc everywhere, so check-components-built attests gcc alone; this builds the whole tree with clang into a scratch directory and asks the same question of the result. Skips when no clang is installed †
check-asm-syntax no inline asm switches the assembler's syntax. An Intel-syntax region receives the compiler's operands %-prefixed, which only GNU as accepts; elsewhere the component fails to compile and its target is removed, so the build reports success with the component absent
check-json-partial-skip -j stays well-formed when SOME components are held back, and says which and why. The guards that pair -j with a skip all use -s '*', where an empty array is well-formed either way; the partial case is the one an index-keyed array separator breaks
check-json-schema docs/kasld.schema.json and the -j emitter describe the same document. Every fixture is replayed under a binary built for ITS architecture, and each emitted key must be declared while each declared key must be reached by that corpus or named in the guard's unexercised list — so a rename trips both directions. Where python3 jsonschema is installed the documents are also validated in full, against the same semantics a consumer's validator applies †
check-slots-subset every window reported as slots out of slots_initial has its count inside that set. The denominator is the leak-free resolution, which sees strictly less evidence and so normally yields a superset — but a rule firing on an observation's ABSENCE can narrow it where the full evidence did not. A format meeting an inverted pair drops the ratio for a bare count, so the inversion leaves no trace in the output it would otherwise corrupt; this is what sees it †
check-env-switches a KASLD_ environment variable is bound to a pointer, never tested directly. A switch is read with kasld_env_enabled(), which treats NAME=0 as off; a value (a path, a release) is bound and checked by its caller, since 0 is legitimate there. Testing getenv() asks only whether the name is set, which turns NAME=0 into ON
check-bundle-prepare one program restores a captured bundle to a runnable sysroot — extra/prepare-bundle. A harness carrying its own copy of the restore builds a tree short a file length, and a run over it resolves one bound fewer with nothing to show for it
check-bundle-containment a restore writes inside the destination or not at all. Each sizes.txt row carries a path from the capture, so a row can name a file outside the destination and the restore would truncate or create it. A .. component is only the obvious route: the mirror keeps its symlinks deliberately, so a link where the file goes, or in place of a parent directory, escapes with a path that reads as ordinary. The legitimate rows are checked in the same run, because a restore that refuses too much leaves the tree short the bound a size-only row exists to carry
check-text-floor no component rolls its own text-base floor — they must use the api.h helper
check-shellcheck shellcheck over every shell script the tree ships — the operator helpers in extra/ and the build, lint and replay drivers under tests/. The directories are discovered rather than listed, so a new one arrives covered; only the exclusions are named (the fixture corpus, and anything deeper than one level, which keeps the kernel-source cache and the fuzz corpora out)
check-fuzz-harnesses every libFuzzer harness under tests/fuzz/ still builds and links against the tree, and has a seed corpus †
check-render-model-only no renderer reads a resolved quantity — a base, count, slide or entropy figure — from the summary struct; the report model is the only source, so a format cannot present a value the model withheld or a figure the engine did not compute
check-render-no-acquire no renderer acquires a fact about the target — no file probe, no sysconf(), no uname(); every fact reaches the render layer through the summary, the report model or the environment snapshot, so all formats answer from one moment and a replayed tree is described rather than the machine replaying it
check-make-deps every test and fuzz binary declares as a make prerequisite each source it reaches by #include, so an edit to one of them relinks rather than leaving a stale binary that passes against code no longer in the tree
check-caller-flags EXTRA_CFLAGS / EXTRA_LDFLAGS carry what the caller asked for and the Makefile never assigns them: a recursive make invocation that sets one on its own command line replaces the caller's value rather than adding to it, so a -Werror supplied to make cross would reach the native build and vanish from every cross target, and both variables still reach ALL_CFLAGS / ALL_LDFLAGS so what is passed is read by something
check-cross-triple make cross TRIPLE=<triple> visits that triple and no other, a sweep with no TRIPLE visits the whole list, and a triple named by TRIPLE whose toolchain is absent fails where the same absence in a sweep is skipped. An ignored selector is invisible in the output — every triple the host cannot build prints the same line either way, and the build is a no-op once the tree is current — while costing a Makefile parse and a set of compiler feature probes per sub-make for every other triple in the list. CROSS_TARGETS is overridden with triples no host can have, so nothing is compiled and the result does not depend on which toolchains are installed
check-vm-ledger the VM boot ledger's record shape holds and the matrix rendered from it still refuses what it must. The shape is stated three times — the field count, the printf that writes a record, the read that splits one — and a disagreement refuses every record as the wrong width, which is loud only to whoever next spends an hour on a sweep. The soundness gate is checked by exercising it: synthetic records are fed to the renderer with LEDGER pointed at a scratch file, so no guest boots and the ledger is the whole input, and a record whose truth lies outside its own window must withhold the table. Nothing else in the suite can catch a broken gate, because every committed boot log is sound — which is exactly why a gate that had stopped working would look identical to one that had not. Also asserted: two boots of one cell that disagree render as a span rather than as whichever was last, the number of boots behind the figures is stated, a truncated line is ignored rather than read with its fields shifted, and a matrix pooling records from different sweeps says so. A boot truncates its pair's live log as qemu starts, so the check also drives the step that runs first: a log never archived is kept before it is overwritten, named by its ledger record only when its own _text proves it is that boot, never kept twice or over an existing archive, and a log that cannot be kept stops the boot rather than being overwritten
check-component-prune the component directory holds exactly the binaries the sources imply: one whose source was renamed or deleted is dropped, since the orchestrator runs every executable it finds there, and a live component's binary or arch-gate stamp is kept
check-suite-registry every unit-test binary the Makefile builds is also executed by tests/run-all, so a suite cannot be added, compiled on every build and never run while make test reports a clean pass
check-property-arches every supported architecture has BOTH whole-engine property tests — test_full_engine_property_<arch> and ..._floor — defined and wired into the RUN() list †
check-stext-gap the three statements of an architecture's _text→_stext head gap agree: STEXT_OFFSET, STEXT_OFFSET_MIN/_MAX, and STEXT_GAP_CANDIDATES †
check-confidence-floor every engine rule that emits a collapsing constraint — C_EQUALS, C_STRIDE, C_AT_LEAST_ALIGN, or C_EXCLUDE — is on a reviewed allowlist, each entry recording what the value rests on †
check-arch-bound-derivation an engine rule derives a bound only from the invariant _ANY_CONFIG window constants; a default-build or _PLAUSIBLE_ constant may be compared against but not derived from, and each rule that does derive from one is on a reviewed allowlist recording what makes it safe †
check-text-provenance a component may claim REGION_KERNEL_TEXT in the sound band only where its source establishes image membership; a range test must yield REGION_KERNEL_TEXT_BAND instead †
check-env-docs every environment variable read outside src/components/ has a kasld(1) ENVIRONMENT entry, and every entry is actually read †
check-validators no arithmetic-input validator in extra/ accepts anything dangerous, and each still accepts a known-good value †
check-arch-macros every macro an architecture header defines is read by something †
check-lattice-seam the quantities held to the estimate accessors (Q_PAGE_OFFSET, Q_VA_BITS) are read through quantity_pinned/window/admits/narrowed, never through .lo / .hi †
check-page-offset-substitution no engine rule or leak component substitutes the compile-time PAGE_OFFSET for the target's linear-map base †
check-render-default no output format names a compile-time layout default (PAGE_OFFSET, KERNEL_VIRT_TEXT_DEFAULT) in code †
check-text-region the KERNEL_TEXT vs KERNEL_IMAGE base contract holds — only reviewed emitters may publish a _stext base
check-image-size the kernel image size is read only through the evidence accessors, never re-derived in a component, and no reader bands its own return value
check-zlib-components which components need zlib is stated once, in the Makefile's ZLIB_COMPONENTS: the files whose code tests HAVE_ZLIB must be exactly that list's sources, and CI and the VM harness must check zlib linkage through make verify-zlib rather than inspecting symbols themselves. A component that grows a HAVE_ZLIB path without joining the list builds without zlib even in a release — it compiles, it runs, and on a target without zcat it quietly stops decompressing, while every check passes because none of them knows to look at it. A HAVE_ZLIB path in a shared header is refused for the same reason: it would reach components that are not on the list
check-image-align every captured x86 kernel's own statement of its architectural minimum alignment (boot_params.hdr.min_alignment) agrees with the IMAGE_ALIGN the matching build carries †
check-doc-alignment the per-capture alignment figures documented in kaslr.md — grain and slot count — are what the tool actually reports for those captures †
check-dram-base where physical RAM begins is read only through evidence_lowest_dram_base(), never re-scanned in a rule †
check-hash-parity every hashed offset-table row's key recomputes to the stored value under the shipped kasld_fnv1a64(), so the runtime hash and the offline generator's cannot drift apart
check-manpages the set of long options in each program's --help exactly matches the set its man page documents, so a new or removed flag cannot skip its manual entry
check-version the version-carrying files stay in step, so a release cannot ship a binary claiming one version while the man pages claim another
check-fdt-unflatten round-trip test for tests/fdt-unflatten: build a known DTB, expand it to the /proc/device-tree layout, assert nodes and values survive
check-ksymoff known-answer tests for extra/ksymoff
check-posture-diff behavioural test for extra/posture-diff
check-posture-summary behavioural test for extra/posture-summary
check-baseline the no-component baseline (-s '*') renders in every output mode and exits with the no-results status, and a run that gathers evidence resolves a window inside it †
check-render-parity the text readout, the markdown report and JSON name the same set of resolved quantities for a given run, agree on every candidate count and on the set each is measured against, and every region the readout lists evidence for is carried by the markdown report under its section name; the host runs are compared under --verbose as well, where the markdown report's per-result table and disposition list must agree with JSON †
check-render-color coloured output is byte-identical to plain output once the escape sequences are removed, and markdown, JSON and oneline carry no escapes at all †
check-wire-text a component cannot put an escape sequence on the terminal: a record whose name, or a disposition whose gate or msg, leaves printable ASCII is rejected, and the verbose echo of component output strips control bytes †
check-sysroot-containment a KASLD_SYSROOT too long to build a fact path with fails the read instead of falling back to the analysing host's own /proc and /sys †
check-uname-release KASLD_UNAME_RELEASE applies only alongside KASLD_SYSROOT: it names the kernel a capture came from, so a live run reports the kernel it is actually running rather than the one the variable names †
check-guard-docs this table lists exactly the guards make lint runs — the same parity check check-manpages applies to flags, applied to the guard list itself
check-matrix-summary the summary table in docs/reproducibility.md restates the full per-scenario matrix it precedes: same cells, same KASLR state, same default and perf-open results in both directions, and the counts the surrounding prose states — the fold's row count, how many omitted-scenario rows depart from their cell's default, and how many pairs disagreed across their boots over how many cells — each read through a fixed phrase, so rewording the sentence fails the guard rather than quietly disabling it. The boot count is not among them: it changes with every run, so the renderer prints it beside the rows and only its presence is asserted
check-readout-docs documented sample output uses the renderer's current vocabulary; the readout blocks among them fit 100 columns; and a block marked as replayed appears verbatim and contiguously in the output of the command the document prints beside it (live output is measured separately by check-render-width) †
check-doc-structure every committed .md has balanced code fences, a complete table of contents where it has one, and a stated section count that matches its numbered sections †
check-doc-identifiers documentation names things that exist: project identifiers cited in backticks resolve somewhere in the tree, and every documented KASLD_META key is read by the code †
check-diagram-data a diagram drawn from a table still agrees with it: every architecture, version and constant the source table names appears in the SVG and nothing else does, and every diagram is referenced, well-formed, and free of glyphs a generic sans-serif may not carry †
check-diagram-palette every committed diagram titles itself in the one house colour. The set was brought to a single palette by editing the SVGs, but three of them are generated and their generators were not touched, so the next regeneration silently restored the old colour — an SVG diff is not read, and a wrong title renders as a perfectly good diagram. The rendered value is asserted rather than the generator, which catches the drift wherever it comes from and needs neither a list of which files have generators nor a way to run them. Both spellings are collected, the .title rule and a fill on the element, because an inline attribute overrides the rule and a check that stopped at the rule would call a wrongly-rendering file correct. Only the title is pinned: the set uses 58 colours across bars and shading, and a diagram with no title is not a failure
check-arch-axes every axis an arch/<arch>.h must define is documented, and every axis documented as mandatory is really one api.h refuses to compile without
check-arch-headers every contract arch/<arch>.h defines every mandatory axis, on any host. api.h enforces this at build time, so it only reaches architectures whose toolchain is installed — an incomplete header for an absent target leaves make, make test and make lint green. Each header is instead put through the preprocessor here, standalone and with no cross toolchain, and asked the same question api.h asks. Refusal stubs are told apart by the nesting depth of their #error: a stub's is unconditional, while arm64.h's module-band relation assertions sit a level deeper and must still be checked
check-arch-dispatch every arch header is reachable and every dispatch arm has a file. api.h selects one header through a chain of #elif arms and is the tree's only includer, so that chain is the whole reachability story. A header added without an arm is dead — its architecture falls through to the generic #error — and an arm naming a missing header fails only when that architecture is built, which for the five refusal stubs is never, so a misspelt include would turn a named refusal into "No such file" indefinitely
check-arch-names every way of naming an architecture to KASLD agrees on one name. The arch header's basename is that name, and anything reaching a build, a fixture or a header from a machine string or a compiler triple converts into it. Three things hold the conversion and none of their drifts fails on its own: tests/lib/arch-names.sh does it, extra/collect carries an unavoidable private copy because it runs where this tree is absent, and the cross matrix supplies the triple spellings the table must resolve. The copy is compared by ANSWER rather than by text, since a capture recorded under a name nothing resolves reads as a foreign machine and is refused to the build that models it
check-macro-claims a comment stating a macro's value states the truth. Comments pin down an arch axis in passing — "the phys pin is inert (KASLR_DISABLED_PINS_PHYS=0)" — and nothing read them, so two components claimed KASLR_DISABLED_PINS_VIRT_TEXT=1 for architectures whose header says 0, each then naming the wrong rule as the consequence. Both compiled and passed every suite, because a comment is not compiled. The checkable macros, their values and the architecture a file is bound to are all derived — from arch/*.h and from api.h's own dispatch chain — so a new axis or architecture is picked up without editing the guard. Only boolean axes are checked, since a size or address is written in prose with units and in hex; files with no arch gate, and gates whose candidate architectures disagree, are counted and reported rather than guessed at
check-fail-closed every component exits cleanly with /proc empty. A component reads files a container, a hardened host or a masked mount can all remove; reading nothing is ordinary and must exit cleanly, while dying on a signal is a read the component never checked — the failure a restricted vantage produces first. KASLD_SYSROOT points at an empty directory and each component binary runs directly. Cross targets are the point: ten components compile to a zero-byte file on x86_64, so a native-only sweep steps over them with [ -x ] and they had never been run with /proc missing on any host — four of those are the KASLR-off signal emitters, whose safety property is precisely that a failed read yields no signal. Where make cross has been run and qemu-user is present each target is swept under it, in parallel; absent toolchains and absent qemu binaries are named rather than failing the guard
hardening-fixtures the -H hardening advisor holds its structural invariants when driven over the captured x86_64 sysroots, and its markdown report states the same gates, suggestions, components and CVEs as its JSON †
cli-flags the argument parser, chiefly short-flag bundling (-fq == -f -q), which main()'s option loop cannot be unit-tested for (main is compiled out under -DKASLD_TESTING). Same note on the name as above

check-truncation needs i686-linux-gnu-gcc, check-shellcheck needs shellcheck, check-fuzz-harnesses needs a compiler that links -fsanitize=fuzzer, check-make-deps and check-suite-registry need python3 and a make that prints its rule database (-p), and check-diagram-data uses xmllint for its well-formedness pass; all six skip cleanly (exit 0) when their tool is absent, so make lint works with just a host compiler. CI installs the full toolchain, so there all six run for real. check-diagram-data skips only that one pass — its table-parity and structural checks need nothing beyond POSIX utilities.

Guard notes

A guard marked † in the table above carries a note here: what it asserts is in the table, and this is the failure it was built to catch. Several were written after the bug they now prevent, and the account of that bug is the reason the guard is shaped the way it is.

check-discard-accounting — The shipped binary, with its worker pool running, counts discards exactly — N components x M bad wire records must yield total N*M in N kinds, repeated. The unit tests are single-threaded and their build defines no HAVE_PTHREAD, so nothing else exercises the ledger's mutex; probabilistic, so a failure is conclusive and a pass is evidence.

check-discard-report — The ledger's two renderings agree with a store actually full — a component overflows MAX_SCALAR_FACTS, the ledger is driven past its own MAX_DISCARDS, and the component directory past MAX_COMPONENTS; --verbose and -j must name the same total, reason and source, the capacity detail sentence must be printed, and the total must keep counting after the breakdown caps.

Counts are differential, since the absolute overflow depends on what else populated the store — which varies by build, not by tool.

check-scalar-seed-order — The arch's compile-time KASLR-off facts are seeded into scalar_facts[] before the phase loop, and only capture_scalar() and seed_arch_kaslr_facts() append to it — appended at summary time instead, the pair competed with components for a 64-slot table and a full table dropped it with no ledger entry; the ordering is invisible to the suite, which stays at full marks with the call moved.

check-vantage-coverage — Every filesystem source kasld_gather_vantage() reads is staged by a test, the suite actually calls the gatherer, and the absent direction is asserted — the gatherer was once constrained by nothing at all, a memset stub leaving the suite green, because the tests named "vantage" asserted on the formatters over a hand-filled struct.

check-test-staging — Every test binary stages its filesystem through test_sysroot.h, which names the root after the binary and registers its own removal — fifteen tests each carried a private mkdtemp, of which eleven removed nothing, so a passing suite left a tree per binary under /tmp to accumulate indefinitely, with nothing ever failing.

check-discard-ledger — Every reason in the discard vocabulary has a wire name, no layer keeps a private drop-counter beside the ledger, and the renderers read it through its accessors — a run that discarded evidence resolved from a subset of what was available, so a consumer unable to see the discard reads a bounded answer as a complete one.

check-fuzz-harnesses — Every libFuzzer harness under tests/fuzz/ still builds and links against the tree, and has a seed corpus. A harness names the parser it drives by #includeing the source file holding it, which makes it the only test that follows the orchestrator's internals rather than its output — and that is how it rots: moving a global to another object, or retiring one, stops the harness linking while every other test stays green.

make fuzz sits outside the default build graph so that a missing clang stops nobody, which also means nothing else would ever notice. It drives the real make fuzz rather than reassembling its command line, so it cannot pass while the target fails, and it asserts a binary exists for every harness in the tree, so one the build never reached cannot pass as one that built cleanly. Needs a compiler that links -fsanitize=fuzzer; skips loudly otherwise.

check-clang-build — A compiler whose assembler is not GNU as builds every component. The tree is built with gcc everywhere — the host, the seventeen musl cross targets, CI — so check-components-built can only ask its question of a gcc build, and nothing asked it of any other.

Three inline-asm blocks switched the assembler to Intel syntax and then referred to operands, which the compiler substitutes in its syntax: %0 arrives %-prefixed inside a region declaring no prefix. GNU as tolerates that and clang's integrated assembler does not, so four leak components failed to compile under clang — silently, because the component recipe removes a target whose compile failed so the rest still build. check-asm-syntax now forbids that construct; this forbids the class it belongs to.

It builds through make rather than assembling its own command line, so it cannot pass while the real build fails, and into a scratch BUILD_DIR rather than ./build, so the guards that sweep build/*/ do not silently gain a target as a side effect of this one running. Roughly ten seconds; skips when no clang is installed, and CI installs one for check-fuzz-harnesses already.

check-slots-subset — A residual is only meaningful against the set it is measured from. The readout states one as "1 of 477" and the document as slots out of slots_initial, and the pair says something only while the count lies inside the set.

That ordering is not guaranteed by construction. The denominator comes from the leak-free resolution: the same rules over the same run with every observation that locates the kernel withheld. Less evidence normally means no more constraints, so the window it leaves is a superset of the resolved one — but a rule that fires on an observation's ABSENCE can narrow it where the full evidence did not, and would then state a denominator below its own numerator.

Nothing downstream would report that. A format meeting an inverted pair drops the ratio and prints a bare count, which is right for a reader and wrong to leave unexamined: the inversion vanishes from the very output it would corrupt, and every other test still passes. Checked against the json emitter, since check-render-parity already holds that in step with the readout and the document names the quantity a violation belongs to. The walk finds any object carrying both counts rather than naming the quantities, so one added later is covered the day it is emitted.

check-json-schema — A schema is trusted harder than the prose it replaces: a consumer validates against it, generates types from it, and stops reading the documentation. So a schema that has fallen behind the emitter is worse than no schema, and nothing about a renamed key announces itself — the renderer still produces well-formed JSON and every other test still passes.

The guard is two halves. The key-set comparison is pure standard library and never skips, because it is the half that holds the contract: every key a document emits must be declared, and every key the schema declares must be reached by the corpus or named in the guard's unexercised list. One rename trips both directions at once — the new spelling is undeclared, the old one is orphaned. Full validation of types, enums and required needs a real JSON Schema validator and soft-skips without one; reimplementing a subset here would check the schema against semantics no consumer runs.

The corpus pairs every fixture with a binary built for ITS architecture, native or under qemu-user. That pairing is what makes required a claim worth making: roughly two fifths of the document's key paths are absent from at least one architecture, so a single-architecture sweep would leave them undeclared and a required set authored from one machine would be wrong on the rest. Which keys are required is therefore read from the emitter's own conditionals, not from what the corpus happens to contain — a key emitted under an if can be present in all sixty-odd documents and still not be guaranteed.

The unexercised list names what fixtures cannot reach: a derived-confidence record, the speculative sub-windows, and the live-only evidence — a confirmed mitigation, a hardware side channel that fired. Holding it exact in both directions is what stops it becoming a waiver.

check-property-arches — Every supported architecture has BOTH whole-engine property tests — test_full_engine_property_<arch> and ..._floor — defined and wired into the RUN() list. The two check different things and neither implies the other: containment says the resolved guaranteed window still holds the truth over random legal truths and random subsets of faithful leaks, while the floor invariant says a below-floor signal may shape likely and moves no guaranteed quantity, with the same pin at CONF_PARSED proving the injection is live.

Both must be per-arch, since each generator encodes its own windows, alignments and layout relations, and each arch's gated rules run nowhere else. The arch list inside the test file is a hand-maintained #if chain, so without this a new architecture header arrives with no property test of either kind and the suite stays green — the same shape as the hand-maintained fuzz-target list that silently stopped building a harness. Makes the arch headers the inventory and the test file answer to them; a definition nothing calls counts as missing. Pure text, no build.

check-stext-gap — The three statements of an architecture's _text→_stext head gap agree: STEXT_OFFSET (the value this build most likely has), STEXT_OFFSET_MIN/_MAX (the sound edges where the linker does not fix it), and STEXT_GAP_CANDIDATES (the admissible values where the arch can close the set). One fact, up to three declarations, nothing in the compiler holding them together.

The list must ascend, start at MIN and end at MAX, and carry more than one entry. The asymmetry is the point: ends that disagree merely bound the base by one set while carving it by another, but a multi-valued list with MIN == MAX reads as an exact gap, so a _stext witness pins instead of bounding — the unsoundness the range was introduced to remove, silently reinstated. Pure text, no build.

check-confidence-floor — Every engine rule that emits a collapsing constraint — one that reduces a quantity to a point (C_EQUALS), a residue class (C_STRIDE), an alignment grid (C_AT_LEAST_ALIGN), or carves a hole in it (C_EXCLUDE) — is on a reviewed allowlist, each entry recording what the value rests on. Such a constraint can exclude the truth from the guaranteed window, the one thing it must never do; a value resting on a default or convention belongs at CONF_HEURISTIC, shaping likely only.

The check reads nothing but the presence of the constraint, and not how the line is spaced. Two earlier forms failed open. The first matched confidence literals in the source text. The commonest spelling of all carries no literal to match — inheriting an observation's confidence into a value the rule computed from that observation — so it passed unexamined. Where a rule computes rather than reads, the arithmetic between the fact and the constraint is what needs review, and no pattern-matching on confidence can see it. The second scanned only rule files, so a rule emitting through a shared helper in engine_rules.h named no op of its own and went unreviewed — which is how an unsound C_STRIDE reached the guaranteed window on arm64. Helpers are now discovered from the header, so a new shared emitter brings its callers into scope on its own.

Bounds are deliberately out of scope, not because a bound placed past the truth is harmless — it excludes it exactly as a wrong pin does — but because the whole-engine property tests already check every at-floor constraint of every kind against a generated truth. What those cannot cover is an architecture with no generator, or an evidence shape a generator does not produce; this list is the human half, and it discriminates only while it stays small enough to be read. Checked for staleness in both directions, since an entry naming a rule that no longer constrains is how the next one gets waved through.

check-arch-bound-derivation — The arch headers carry three kinds of window constant, answering three different questions: *_PLAUSIBLE_MIN/MAX is this address absurd for this architecture, *_MIN/MAX_ANY_CONFIG what is the widest placement any build admits, and VIRT_TEXT_MIN/MAX_DEFAULT_CONFIG, KERNEL_VIRT_TEXT_DEFAULT, KERNEL_PHYS_DEFAULT where a default build puts the image. Only the middle kind is invariant, so only it may be derived from. The other two may be compared against.

Comparing is safe because of what the comparison is for: the dominant use is the redundancy test — if (ceiling <= VIRT_TEXT_MIN_DEFAULT_CONFIG) return 0; — which declines to emit a bound no better than the architecture already implies, so a wrong constant suppresses a constraint rather than excluding the truth. Deriving is the opposite: the constant becomes the value of an emitted bound, and a bound inside the true window removes the base from the guaranteed answer. The two families diverge from _ANY_CONFIG on real architectures — x86_64 and s390x on the physical floor, x86_64, aarch64, s390x and riscv64 on the virtual one, aarch64 on the virtual ceiling — so a rule that reads one and is later extended to a diverging architecture becomes unsound with no signal in the code.

Classification is mechanical and fails closed: a line counts as a comparison only where every occurrence sits directly against a relational operator and is followed by a close paren or a logical connective, with casts stripped first. Arithmetic, an assignment, an argument, or a comparison split across two lines all read as derivations and need an allowlist entry. Each entry must say which architectures the rule is gated to and that the constant equals its _ANY_CONFIG counterpart there — that fact is what makes such a rule safe, and nothing in the code records it.

A rule can also reach a constant without naming it, through a shared inline helper, which the token scan alone cannot see — the hole check-confidence-floor was bitten by. Those helpers are discovered from the headers and classified by what they do with the value: masked to the image-base grid phase (whose low bits come from the linker rather than a Kconfig), used as a comparison bound only, or returned. Only the last puts its callers in scope. A helper that starts holding one of these constants fails the build until it is classified. An entry may instead record that the rule reads only the grid phase of a default (its low bits below the slide granule, which come from the linker rather than from a Kconfig), or that the derived bound is confined to CONF_HEURISTIC and so shapes likely alone. Checked for staleness in both directions.

check-text-provenance — A component may claim REGION_KERNEL_TEXT in the sound band only where its source establishes image membership; where the region rests on a range test it must come from kasld_addr_classify(), which returns REGION_KERNEL_TEXT_BAND wherever the windows are not exclusive. The text window is the KASLR-admissible range, not the image's extent, so on most architectures it contains the linear map, the module band, or both — [0x40000000, 0xf0000000] on ppc32/arm32/x86_32, and beginning at PAGE_OFFSET on ppc64.

kasld_addr_is_directmap() is written as "below the text window", which makes that window empty exactly where the two collide, so a classifier asking the predicates in order resolves every ambiguous address in favour of text — silently, and always toward the strongest tag. That matters because an interior-image sample implies image_base <= sample: a direct-map pointer tagged as text and sitting below the real _text carves the truth out of the guaranteed window. Both halves were reproduced — a task_struct from the ZFS debug log came back kernel_text pos=interior conf=parsed on ppc32, ppc64 and s390, and the same shape in /proc/<pid>/syscall put the true base outside the guaranteed window on 2 of 5 boots of a 5.9 ppc32 kernel.

Scope is at-or-above the sound floor, since a sub-floor text claim cannot bound the guaranteed window whatever its region says. The allowlist records what carries the proof for each entry — a symbol resolved by name, an instruction address, an ELF program header — and is itself checked for staleness, because an entry naming a component that no longer claims text is how the next real offender gets waved through. It does not trace values: it forces the question to be asked and records the answer.

check-env-docs — Every environment variable read outside src/components/ has a kasld(1) ENVIRONMENT entry, and every entry is actually read. Component-exclusive variables are excluded deliberately: a component is a standalone program whose debugging knobs belong to it, not to the orchestrator's interface, and documenting them would oblige one page to track 100+ components' internals. Two of them — KASLD_COMPONENT_DIR and KASLD_EXEC_WRAPPER — name programs kasld will execute, so an undocumented one is an execution knob invisible to anyone reviewing a sudoers rule or a packaging script. The same parity check check-manpages applies to flags; documentation fixes the surface once, this keeps it fixed as the surface grows.

check-validators — No arithmetic-input validator accepts anything dangerous. extra/check-results and extra/ksymoff both feed parsed fields into shell arithmetic, where $(( x )) evaluates embedded command substitutions — and check-results is documented as running under sudo, so a value like a[$(cmd)] reaching it would be root command execution. The validator is duplicated four ways because neither script can source a library (ksymoff installs to $PREFIX/bin; check-results is copied to a target), so a correction to one does not reach the others.

What is asserted is rejection, not sameness: the four accept different sets on purpose. Also asserts each one accepts a known-good value, so a validator that rejected everything could not pass vacuously, and that the @arith-validator marker count matches the number exercised, so a new one cannot escape the corpus.

check-arch-macros — Every macro an architecture header defines is read by something. A name nothing reads is a misspelling, a retired spelling one header kept, or dead weight — and the first two are silent: the architecture falls back to the contract's default for the macro it meant to set, which costs precision with nothing to show for it. No test catches that, because the tests read the same declaration the code does and assert whatever it says. Complements the retired-spelling #errors in api.h, which fail the build for one known-old name; this catches the names no such check lists.

check-lattice-seam — The quantities held to the estimate accessors (Q_PAGE_OFFSET, Q_VA_BITS) are read through quantity_pinned/window/admits/narrowed, never through .lo / .hi. struct estimate means different things per lattice — on a finite set lo is a live-candidate bitmask and hi is unused — and which lattice a quantity uses is declared once in the quantity table, so a direct read hard-codes an answer the reader never asked for.

Nothing would fail loudly: a bitmask read as an address is a small integer, so the result is a plausible wrong answer rather than a crash. The pointer alias is discovered from its binding rather than assumed to be named po, so renaming it cannot slip a read past.

check-page-offset-substitution — No engine rule or leak component substitutes the compile-time PAGE_OFFSET for the target's linear-map base. That constant describes the analysing build, not the kernel under examination, and on the VMSPLIT arches the two differ routinely — code that reaches for it is asserting the split it was compiled with. The failure is invisible: it compiles everywhere, passes on the whole default-split corpus, and is off by exactly the gap between two build configurations, which is zero on every machine anyone tests.

In a rule, an equality must read the resolved Q_PAGE_OFFSET via quantity_pinned(), and a bound may instead use PAGE_OFFSET_MAX (upper) or PAGE_OFFSET_MIN (lower), which hold against every target and need no resolution. A component runs before inference and can never see an estimate, so it measures the boundary instead — kasld_kernel_pointer_floor() for the user/kernel split, kasld_page_offset_floor() for a region-tagged bound.

Comments and string literals are stripped first, and #if / #elif lines are exempt by construction (a constant expression cannot call an accessor, which is why the band assertions keep PAGE_OFFSET a plain scalar), so only C code counts.

check-render-default — No output format names a compile-time layout default (PAGE_OFFSET, KERNEL_VIRT_TEXT_DEFAULT) in code. A renderer printing an address asserts it, and these are link-time constants of the analysing build rather than measurements of the target — presenting one as the answer states a wrong address at full confidence on any kernel built differently, which has happened twice in two different renderers. Showing a default as a default is fine via the published layout field; using the linear-map base as an answer goes through kasld_page_offset_if_known(), which yields the constant only where a single base is admissible. No exceptions — a new one means that accessor needs extending.

check-dram-base — Where physical RAM begins is read only through evidence_lowest_dram_base(), never re-scanned in a rule. Four rules need it, and on the architectures whose kernel sets its physical offset from the base of DRAM that value is the address mapped at PAGE_OFFSET — so two rules disagreeing about it anchor the linear map differently and shift a guaranteed window rather than widening one.

The filter is the substance: REGION_RAM with POS_BASE and nothing else, which is the kernel's account of its own memory rather than firmware's account of the board, and a bank the kernel rejected would drag the anchor below the real one — the dangerous direction, since one consumer emits C_EQUALS. Before the accessor existed the same loop was copied into every caller and the comments promised an agreement nothing enforced.

check-baseline — The structural baseline — what a run with no component at all (-s '*') reports — renders in every output mode and exits with the no-results status, and a run that does gather evidence resolves a window inside the baseline window. The baseline is the architectural top over an empty evidence set, so evidence may only narrow it; stated as containment, the check needs no per-architecture table and no ground truth. Also sweeps every cross binary present under qemu-user, which needs no fixture and reaches arch headers no fixture covers.

check-render-parity — The text readout, the markdown report and JSON name the same set of resolved quantities for a given run. The Layout row model exists so no two formats can describe one resolved state differently, but it only binds a format that consults it: the no-randomization postures once returned before the model was built and then hardcoded the kernel image base, so a quantity the engine had pinned reached JSON while both readouts omitted it. It also compares the Evidence sections, one way: the readout reports the position of each region whose placement is a resolved quantity, while the markdown report reports the extent observed per section over a wider set, so containment rather than equality is the invariant — a region the readout evidences must appear in the markdown table.

The host runs are compared under --verbose as well. Verbose is a different renderer, not a longer one: the markdown report draws one row per result instead of one per group and adds a per-component disposition list no other mode prints. Those rows are checked for the column count their own header declares — an unescaped pipe in a cell shifts every column to its right — for an address rendered as one, and for an origin the run published a component for, while the disposition list is compared against the dispositions JSON carries. The cross sweep stays on the answer-first output: a foreign-arch verbose render costs an emulated run per format, and verbosity is not the axis those targets are there for.

Compares text and markdown row for row — quantity, grade, range, search space and pitch, normalised so neither format's column padding nor its scaffolding counts — because a renderer that alters a displayed value or drops a grade changes none of the names. Both had happened: one renderer snapped a window onto the alignment grid while the others printed the raw edges, and two posture-specific renderers dropped the likely grade outright. The JSON arm stays a one-way name check, since the readout draws a row per randomized quantity whether or not it is bounded while JSON emits one only when it has something to say; every quantity must still have a name mapping, so adding one forces stating how each format names it.

check-render-color — Coloured output is byte-identical to plain output once the escape sequences are removed, and markdown, JSON and oneline carry no escapes at all however the environment asks for colour. Every other render guard runs through a pipe, where colour is off, so the escape-emitting path went unmeasured — and it is not a simple wrapping of a finished cell: the text table pads a column from the cell's plain length while colouring part of the text inside it, so a mistake there misaligns the table under a terminal and nowhere else, leaving plain output byte-identical and every other guard green.

Each case also asserts the coloured run actually emitted escapes, since a differential against a colourless run passes while proving nothing. Determinism comes from an empty sysroot plus stub components, which also supply the pinned base the coloured branches need.

check-wire-text — A component's free text is data, and the fields carrying it — a result's name, a disposition's gate and msg — are rendered into the report an operator forwards. An erase-line sequence among them redraws a line already printed, so a finding can be made to read as its opposite by the report meant to expose it. check-render-color proves KASLD's own escapes strip back to the plain rendering, which says nothing about escapes arriving in data.

The admissible set stops at 0x7E rather than merely above 0x1F, because 0x80..0x9F is the C1 control range and a terminal in an 8-bit locale acts on it with no ESC byte involved. Both halves are exercised. The guard also reads its own output with grep -a: without it a high byte makes grep report a binary match instead of lines, leaving the check searching nothing and passing against the very build it targets.

check-sysroot-containment — kasld_resolve() composes <KASLD_SYSROOT><path> into a KASLD_PATH_MAX buffer, and returning the bare path where the two do not fit sends the read to the analysing machine's own /proc and /sys while the output still presents a captured tree. It is not a truncation trade-off: a prefixed path overflowing 4096 bytes is already longer than one the kernel will open, so the fallback never salvaged a read that would otherwise have worked. Before the fix, a 4091-byte sysroot naming nothing read 124 facts where a short one naming nothing read 3.

Both roots name nothing, so a difference between them can only be a read that escaped. A live run supplies the control: a host exposing no more facts than the empty sysroot does leaves nothing to detect, and the guard skips rather than passing on an absence.

check-uname-release — KASLD_UNAME_RELEASE names the kernel a capture came from, and honouring it with no capture labelled a scan of the local machine with a kernel that machine is not running. The document's own provenance flag still read replay: false — correctly, since every fact was live — so nothing in the report contradicted the substituted release, and a source whose path carries the release stopped resolving as well. Both halves are asserted, since either alone can hold for the wrong reason: ignored on a live run, still supplying the release for a capture that states none.

The two expectations are relations rather than values — the live release against this host's own uname(2), the captured one against the string handed to the run — so the guard asserts nothing about the kernel it happens to run on.

check-image-align — IMAGE_ALIGN is the alignment _text is guaranteed to have: the smallest an admissible build can use, not the value a default build happens to get. The distinction is load-bearing, because the constant drives the grid image_base_grid_align snaps a resolved window onto — so a figure taken from one config raises a floor past a finely aligned base and drops the truth out of the guaranteed window.

x86_32 carried the Kconfig default (2 MiB) where the range starts at 0x2000, with the header comment beside it recording the real range the whole time. Nothing compared the two.

x86 kernels publish the answer, so on x86 it can be compared rather than reasoned about: the setup header's min_alignment at offset 0x235 is MIN_KERNEL_ALIGN_LG2, which is PMD_SHIFT on x86_64 and PAGE_SHIFT + THREAD_SIZE_ORDER on x86_32 — all three unconditional, so it is a constant of the architecture rather than of the build, and the Kconfig range for CONFIG_PHYSICAL_ALIGN starts at exactly it. Every captured x86 kernel in the corpus is checked against the build for its architecture; each is a different real kernel, so a disagreement means this tree's arch header is wrong about the architecture rather than about one boot.

Not a component or an engine rule, deliberately: the value equals the architectural floor kaslr_align_arch_default already asserts as an axiom, so reading it at runtime would emit a constraint the engine holds already. Its worth is entirely as a check on the constant.

check-doc-alignment — kaslr.md states the KASLR slot granularity per architecture, and one subsection qualifies that for x86, where the granularity is a build option rather than an architectural constant. It illustrates the point with named captures: a distro kernel built at the 16 MiB Kconfig ceiling against one at the 2 MiB default, and the slot count each yields. Those are the only figures in that document tied to a specific kernel rather than to an architecture, which makes them the only ones that can go stale without anybody editing the document — refreshing a fixture is enough.

The guard runs kasld against each capture the table names and compares the readout to the row: the documented kernel_alignment against the row's Grain, the documented slot count against the denominator of its Candidates.

End-to-end rather than a read of the capture's setup header, deliberately. The header holds the value the document names, but what the document claims is that the value reaches the readout and thins the slot count in proportion — so reading the header would confirm the input and leave the claim untested. It therefore also catches an engine or renderer change that stops the value being honoured.

The marker that identifies the table names an architecture, because a capture name does not identify a capture: tests/fixtures/arm64 and tests/fixtures/x86_64 both hold an alpine-3.21-6.12.81-0-virt. Resolving the name by search takes whichever sorts first, which is how this guard first reported OK while checking the wrong architecture.

Only the rows of that table are checked. The prose around it also attributes values to distributions, which is a claim about the corpus as a whole; encoding it here would put a second copy of the document's claim in the guard, and a guard that disagrees with the document it guards is worse than none. Naming a capture in the table is what brings a figure under test.

check-doc-structure — Three failures markdown accepts silently and a reader meets as a broken page: an unclosed fence swallows the rest of the document, a heading added without its TOC line is unreachable from the contents list of a 900-line reference, and an opening sentence like "has seven test layers" is the one claim a reader takes on trust before reading further. TOC parity is checked only where a document has a TOC -- adding one is a choice, keeping it complete is not.

check-diagram-data — Three of the eighteen diagrams plot data that lives in a markdown table elsewhere in docs/. Nothing tied the two together, and an SVG drifts more quietly than prose: nobody reads its diff, and a stale chart looks exactly like a current one. The generated chart once drew the source table's |---| separator as though it were an architecture -- a row labelled with dashes that no consistency check caught, because it was equally present in the generator's output and in every regeneration of it.

What is asserted is membership, not the plotted values: residual bit counts are a sample that moves with each harness run, so pinning them would fail on every honest re-run, while the set of things plotted does not move. The other thirteen diagrams illustrate a mechanism rather than plot a table, so they have no source to check against; the structural half -- referenced, well-formed, no arrow or box-drawing glyphs -- covers all eighteen.

check-doc-identifiers — The same parity check-manpages applies to flags, applied to names. A document naming a constant that does not exist reads exactly like one naming a constant that does: CONTRIBUTING.md carried REGION_MODULE_REGION, which nothing has ever defined, in the same table as the real constants. The check catches the commoner direction too -- a constant renamed in src while the docs keep the old spelling. CONFIG_* is out of scope by construction, being the kernel's namespace rather than this tree's. Nothing being checked may vouch for itself: the script excludes its own text, whose header names retired spellings as examples, and excludes markdown generally, since the documents under check are markdown and extra/* would otherwise pull extra/README.md into the corpus grading it. The corpus takes the harness directories whole rather than script by script, because every tests/*/README.md is checked and a harness whose script were missing would be graded against a corpus that could not hold the answer.

check-readout-docs — Documented sample output uses the renderer's current vocabulary, and the readout blocks among them fit 100 columns (live output is measured separately by check-render-width, against the tool's own wider budget) — the README and docs/ carry hand-maintained copies of rendered output with nothing tying them to the renderer, so a rename or column change silently leaves them describing a version of the tool that no longer exists.

Vocabulary and arithmetic are not enough on their own: a sample assembled from several runs passes both while describing a run that never happened. One did — a bare - candidate count beside an entropy line that prints only when the count is above zero, and two region rows whose stated counts were each one short of what their own windows imply. So a block a document presents as output is re-derived rather than read: <!-- replay: <fixture> <flags> --> above the fence names the run, the guard repeats it, and the block must appear in the output as a contiguous run of lines. Contiguity is the load-bearing part — it is the property a composite fails. Trailing whitespace is stripped from both sides, since the Layout header pads its last column and the documents carry none.

The fixture must also be named by a command earlier in the document, so the block stays reproducible by a reader rather than only by this guard. That is searched from the top rather than within a fixed window: a second excerpt of one run is introduced as such rather than by restating the command.

hardening-fixtures — The -H hardening advisor holds its structural invariants when driven over the captured x86_64 sysroots. test_render.c covers the meta → gate → suggestion logic by seeding component logs synthetically; this drives the REAL binary over real captures, which is the path that regressed before. Not named check-*: it exercises behaviour over fixtures rather than asserting a source invariant, but make lint runs it and it is part of that contract.

Each fixture is rendered in both formats, and the markdown report must state what the JSON states: the same exposure counts, gate values, suggestions, compile-time surface, unmitigated components and CVEs. The two do not publish identical sets — JSON lists every gate where markdown lists only those active or bypassed, and markdown lists a hardware channel only where it succeeded — so those two comparisons run one way and the rest are set equalities. Only JSON was asserted before, so a markdown table that dropped a component or carried the wrong gate's value showed up nowhere.


2. End-to-end replay (tests/replay)

Reconstructs a scratch sysroot from each fixture under tests/fixtures/<arch>/<host>/ and runs the real kasld over it in every output mode — verbose text (-v), oneline (-1), and the hardening report in text / markdown / json (-H, -H -m, -H -j) — checking each parses, resolves, and renders without crashing. There is no golden master: a run fails on a crash (death by signal) in any mode, or on an output-mode assertion — a mode that emits no marker, or a document mode (-m / -j) that leaks the plain-text run narration. A run that simply yields "no results" is informational, not a failure. The multi-mode sweep is per-arch crash coverage of every renderer, which the host-only render unit tests cannot reach.

Fixtures are real extra/collect captures from real kernels (validated with extra/validate-bundle on ingest), not hand-authored inputs — the corpus exercises KASLD against reality. Synthetic inputs live in the unit tests (layer 1).

This is a structural / regression check, not a soundness check: it confirms KASLD survives real captured kernel state across many architectures and versions, but does not verify the inferred range against a ground truth. Soundness over the fixtures that carry a truth — the subset captured with real kallsyms/iomem (anonymized: 0) — is a separate offline layer, make test-fixtures (see Validating captured bundles); soundness on a live kernel is layer 5 (tests/vm/run). Replay answers a different question from both — does the binary run cleanly? rather than is the result sound?

Native mode (no qemu) — host arch only

make                                   # build the x86_64 binary + components
KASLD_NATIVE=1 tests/replay tests/fixtures/x86_64/* tests/fixtures/x86_32/*

Native mode runs only fixtures the host can execute directly (an x86_64 host also runs 32-bit x86); foreign-arch fixtures are skipped, never failed. The binary is taken from build/<arch>-*/ (any triple). For the x86_32 fixture, build a 32-bit binary first, e.g. make build CC=i686-linux-gnu-gcc (auto-static when cross). This is what CI runs.

Full mode (qemu-user) — all arches

# musl-cross toolchains + qemu-user binaries on PATH:
make cross                             # build every arch's binary + components
tests/replay                           # all fixtures, foreign arches under qemu

Foreign-arch component children do not exec under nested qemu-user, so those fixtures legitimately yield no results — still a pass as long as nothing crashes.

Env:

Var Default Meaning
KASLD_NATIVE unset 1 = run host-arch fixtures directly, no qemu
QEMU_DIR search PATH directory of qemu-<arch> user binaries (override only if not on PATH)
BUILD_DIR ./build where the per-arch binaries live
KEEP 0 1 = keep the last scratch sysroot for inspection

The qemu-<arch> user binaries are resolved from PATH by default (distribution qemu-user installs there). Set QEMU_DIR only when they live elsewhere, such as a self-built qemu in a non-standard prefix.


3. Cross-arch engine tests (make test-cross)

# musl-cross toolchains + qemu-user binaries on PATH:
make test-cross        # or: tests/test-cross

Compiles nine suites — test_engine, test_engine_integration, test_estimate, test_report, test_kasld, test_render, test_addr_parse, test_target_model and test_proc_kallsyms — with each cross toolchain and runs them under qemu-user, so arch-gated rule bodies (#if defined(__aarch64__) …) execute on their own architecture instead of compiling to no-ops on the host. The engine tests are pure, syscall-free C, so this is sound under emulation.

test_report is here for a variant of the same reason. Its assertions are about the report model rather than a rule, but several of the model's rules are stated per quantity and answered by an arch macro — whether the image base has a randomization window at all, for one. An assertion about such a rule is inert on a host whose answer is yes, and only bites on an architecture whose answer is no: running it on x86_64 alone once let a denominator reach every KASLR_SUPPORTED 0 architecture unnoticed.

The engine core and src/rules/*.c are compiled once per target and linked into both engine binaries; USE_CCACHE=0 compiles without ccache, which is what CI sets because a fresh runner restores no cache for a hit to come from.

Covers 17 targets: nine 64-bit (aarch64, riscv64, s390x, mips64, mips64el, ppc64, ppc64le, loongarch64, x86_64) and eight 32-bit (i686, arm, armv7, armeb, mips, mipsel, riscv32, powerpc — ppc32 big-endian). 64-bit-only tests are #if __SIZEOF_LONG__ >= 8-guarded and skip on the 32-bit targets. Targets whose toolchain or qemu-user binary is absent are skipped; exit status is non-zero only if a present target fails.

The one variant not automated here is ppc32 little-endian: the powerpcle musl toolchain exists, but there is no 32-bit-LE qemu-user binary to run it under, so it is validated manually on real hardware or a full ppc32-LE VM.

This runs per-push in CI: the cross-compile matrix (build.yml → _cross-build.yml with run_test_cross) invokes tests/test-cross <triple> for each arch under qemu-user, so a broken arch-gated assertion fails the push that introduces it — the cross-compile job alone would not catch it. With no arguments tests/test-cross runs the full local set; with triples it runs just those (one per CI matrix job).


4. Coverage (optional)

Optional, gcov-based — the normal build/test never use --coverage, so coverage adds no dependency to them. The text summary needs only the compiler's own gcov; HTML appears only if lcov + genhtml are installed.

make coverage          # host unit tests -> build/coverage/
make coverage-e2e      # real binary over x86 fixtures -> build/coverage-e2e/
make coverage-union    # the two above, unioned
  • coverage instruments the engine core + every rule + the test_kasld TU and reports per-file + total line coverage from the host unit tests.
  • coverage-e2e instruments the real binary (no -DKASLD_TESTING) and runs it live + over the x86_64/i686 fixtures, so it is the only report that reaches main(), the engine bridge, and the renderers. x86_64 host only (runs the binary natively). It sweeps every output mode, including the markdown report's three distinct renderers (compact, -v -m, -H -m): a mode the sweep omits reads as uncovered however thoroughly a guard drives it.
  • coverage-union reports the two together. Neither alone answers whether a file is tested — the unit report hides what -DKASLD_TESTING compiles out, the end-to-end one has no unit paths, and a file can read 7% in one and 70% in the other. A line either run executed is covered; a line neither reached is the gap. It reads the gcov data the other two leave behind rather than running them, so a stale half misreports and wants re-running first.

For a clang toolchain, point at its gcov shim:

make coverage CC=clang GCOV="llvm-cov gcov"

The report describes the architecture that built it, and its total says which. A large share of the rule set is arch-gated: off its own architecture a rule compiles to a stub of a couple of lines, which the report scores as fully covered — so a row reading 100.00% of 2 lines marks a rule whose architecture this run did not build, and the host report neither measures those bodies nor admits the omission. Building with a cross toolchain measures them instead, running the drivers under qemu-user, resolved from the compiler's triple:

make coverage CC=aarch64-linux-musl-gcc GCOV=aarch64-linux-musl-gcov \
     CFLAGS_EXTRA=-static

arm64_text_base reads as a stub on the host and as its real body there. No single architecture shows everything — each stubs out the others' rules — so these are per-architecture reports and nothing merges them: gcda from two architectures describes two different sets of lines.

Env: CC (default cc), GCOV (default gcov), CFLAGS_EXTRA, QEMU_DIR.


Validating captured bundles

Not a layer — a tool the layers share. It is documented here, between layers 4 and 5, because the numbered layers on either side both drive it.

extra/validate-bundle runs the arch-correct kasld (under qemu-user for foreign arches) over a bundle's sysroot/, then asserts the engine-resolved range for every reported quantity contains the ground truth captured alongside it — virtual text base from proc/kallsyms (when captured with --kallsyms), physical text base from proc/iomem. Reports PASS / FAIL / N/A per quantity. It serves two roles:

  • Ingest — when a bundle arrives from a real system (a bug report, an external VM), a one-shot extra/validate-bundle <bundle> confirms KASLD is sound on it and decides whether it earns a place in the fixture corpus.

    extra/collect --kallsyms             # capture a bundle on the target
    extra/validate-bundle kasld-bundle-* # run kasld over it, check the truth
  • Recurring soundness gate — make test-fixtures (tests/validate-fixtures) runs validate-bundle over every fixture that carries ground truth, failing on any resolved window that excludes the real base. A capture qualifies by what it holds rather than by how it was prepared: a real (non-zero) kallsyms _text/_stext, or an iomem "Kernel code" line. anonymized: 0 is admitted as well, as the historical marker, but it is not the test — --anonymize redacts host identity and never touches /proc/kallsyms or the iomem kernel line, so most of the corpus is anonymized and truth-bearing at once. This is the reproducible, boot-free complement to tests/vm/run (layer 5): it catches the window-excludes-truth soundness class in CI without a live boot. Native arches validate directly; foreign arches replay under qemu-user (QEMU_DIR or PATH). Truth-bearing fixtures come from extra/collect --kallsyms captures or from tests/vm/run <arch> capture (a live boot that frames the fact-set back over the serial console). It is jq-gated and skips arches whose binary or qemu-user is absent, so it degrades cleanly.

  • Truth-free perturbation gate — make test-fixtures-perturb (tests/validate-fixtures --perturb, extra/validate-bundle --perturb) is the complementary invariant: instead of "does the window contain the truth", it asserts no container-fakeable input may move the GUARANTEED window. It runs kasld over two copies of a bundle that differ only in a container-fakeable input (the cgroup-reported MemTotal/LowTotal, faked with the DRAM extent present and masked) and fails if the guaranteed window shifts. Needing no ground truth, it runs over the whole corpus — including the captures that carry none for the containment gate to use — so every coupled arch's ceiling rules get covered, not just the truth-bearing captures. This is what catches the fakeable-value-reaches-the-guaranteed-window soundness class (e.g. the MemTotal-ceiling bug on the 32-bit and other coupled arches).

A FAIL is a soundness violation — the engine's resolved window excluded the truth. The only legitimate outcomes are PASS (range admits the truth, possibly wide) or N/A (the capture carries no truth to check against: taken without --kallsyms, and with the iomem kernel lines read back as zeros). Tightness is a separate concern.

Bundles are captured from real systems — the machine under test, a system attached to a bug report, or an external test VM — so a PASS is evidence KASLD was sound on a real kernel. The data's provenance is the point: a validated bundle can be committed under tests/fixtures/ as a replay fixture (layer 2), so the fixture corpus is real captures only. Synthetic inputs belong in the unit tests (layer 1, e.g. test_engine for rules, test_dmesg_layout / test_btf for component parsers), never in a bundle or fixture — keeping "this ran on a real kernel" meaningful.

Complements the per-leak validator extra/check-results, which runs on the live system as root and compares each emitted record against live /proc/{kallsyms,iomem,modules}. validate-bundle validates the engine's resolved windows; check-results validates each component's emitted records.

Dependencies: jq, plus the cross toolchain + qemu-user binaries for foreign-arch bundles (same setup as layers 2–3).


5. Live cross-architecture validation (tests/vm/run)

make cross                 # build the per-arch binaries
tests/vm/run               # boot each supported arch, default profile
tests/vm/run all hardened  # repeat under the unprivileged floor

Boots a real, publicly-fetchable kernel per architecture under qemu-system (with KVM where the guest matches the host), runs the cross-built kasld against the running kernel, and checks that the inferred range contains the kernel's true text base. Where extra/validate-bundle validates a single captured system offline, this validates live kernels across architectures and reader-privilege profiles (default / kptr-hidden / perf-open / dmesg-open / bpf-open / hardened / nokaslr).

Unlike replay (layer 2) — which runs offline over captured fixtures and only checks that KASLD parses and runs — this boots a real kernel, so it knows the true base and checks soundness: that the inferred range contains it.

Needs qemu-system-<arch> and the cross toolchains on PATH; an arch is skipped (not failed) when either is missing. After running the scenarios, tests/vm/run table renders the arch × scenario → KASLR / virt residual / phys residual matrix from the boot ledger, one record per boot (soundness is a gate, not a column — it refuses to emit if any cell's window excludes the truth, in any boot). Where REPEATS booted a cell several times and its boots disagreed, the residual is the span they covered rather than whichever was last; the published snapshot is in reproducibility.md. See tests/vm/README.md for the full arch list and options.

Architectures Alpine does not port (mips, mipsel, mips64el, riscv32, ppc32, powerpc64) are built from a pinned kernel.org source by tests/vm/build-kernel — a stock upstream defconfig plus fixed config overlays (endianness, devtmpfs, and text KASLR where the stock defconfig omits it, e.g. ppc32) — then booted by tests/vm/run the same way:

tests/vm/build-kernel mipsel-mainline-7.0   # download source + cross-build -> cache (slow)
tests/vm/run mipsel-mainline-7.0            # boot it, verdict

This is manual and slow; the arch-gated rule logic is covered per-push by make test-cross. armeb is validated: its toolchain emits BE32 by default, which dies with SIGILL on the BE8 userspace an arm kernel runs from ARMv6 on, so both the kasld binary and the harness's init are built -mbe8.


6. Parser fuzz harnesses (tests/fuzz/)

make fuzz                                    # build the harnesses (clang)
tests/fuzz/seed-from-fixtures.sh             # populate the seed corpus
build/fuzz/fuzz_capture_result \
    tests/fuzz/corpus/capture_result/        # run the parser fuzzer

Every harness is built with AddressSanitizer and UndefinedBehaviorSanitizer. Five cover the pure string→struct parsers the orchestrator runs against attacker-influenced input: parse_hex, capture_result, capture_scalar, parse_meta and parse_disposition. fuzz_btf walks the binary BTF type info in btf_facts.c — kernel-provided input rather than an attacker surface, but the most intricate binary parser in the tree. fuzz_render drives the report model built from a resolved engine state the way a format reads it. The Makefile globs tests/fuzz/fuzz_*.c, so a new harness needs no target. See tests/fuzz/README.md for the contract details and crash-reproduction workflow.

Opt-in: make fuzz requires clang with -fsanitize=fuzzer and is not part of the default build graph. The harnesses are not exercised by CI — corpus-guided fuzzing wants hours of runtime per harness, which doesn't fit a per-commit CI budget. The harness binaries land in build/fuzz/ and are not installed by make install (the install glob covers only build/<arch>/ per-arch artifacts).


7. Container / cgroup execution (make test-container)

Checks how kasld behaves when run inside a container or cgroup-constrained namespace — the kernel is the host's, but /proc//sys are masked or virtualized, syscalls may be filtered, and cpu/memory/pids are capped. Two invariant families:

  • Soundness (truth-free) — a restricted or faked input must not corrupt the GUARANTEED window. The live host + x86_32 fixture meminfo check here is the spot-check; make test-fixtures-perturb is the arch-general, CI version.
  • Robustness — a blocked syscall, killed child, failed fork, masked file, or memory limit must not crash, hang, or silently mis-degrade. Covers: seccomp (perf_event_open blocked with SCMP_ACT_ERRNO (EPERM) and SCMP_ACT_KILL (SIGSYS) — must report access_denied, not "found nothing"), a real masked /proc via unshare -Urmpf --mount-proc, fork starvation via an LD_PRELOAD EAGAIN shim (a pids cgroup analogue), a systemd-run memory cgroup, the cpuset pin_cpu fallback. The per-component "fail closed under an empty /proc" sweep is check-fail-closed, in the guard set: it needs no container, and the guard set is where a regression in it is seen.

Opt-in (make test-container, not part of hermetic make test): it snapshots the live host and runs live restrictions. Each LIVE check note-skips cleanly when its facility (seccomp, unprivileged userns, systemd --user, ≥2 CPUs) is unavailable. Because a skip is silent, the summary reports the scope — 11 pass, 0 fail, 2 skipped (11 of 13 checks ran) — and the run fails below a floor of checks actually executed, on the same reasoning as guard_scope: too small a scope is a broken harness, not a clean run. The default floor is the measured facilities-stripped figure, so a host without systemd-run, unshare and taskset still passes at nine while the narrowing stays visible; KASLD_CONTAINER_SCOPE_FLOOR sets it for a runner whose surviving set has been observed.

Behaviour worth guarding hermetically is lifted out of this harness into layer 1, since the harness itself is opt-in and a regression under it goes unseen: the reaped-status → outcome classification, incl. the SIGSYS→access_denied mapping and the any-other-fatal-signal→crashed one that must not swallow it, is unit-tested in test_outcome; the verbose block reporting confinement for a capture that cannot be named is unit-tested in test_render. See tests/container/README.md.


Prerequisites

  • Layer 1 (make check): a C compiler (cc / gcc / clang) and make. Nothing else for the unit tests. The make lint guards optionally use i686-linux-gnu-gcc (check-truncation), shellcheck (check-shellcheck), python3 (check-make-deps, check-suite-registry), xmllint (check-diagram-data) and a libFuzzer-capable clang (check-fuzz-harnesses); all skip cleanly when absent.
  • Layers 2–3 (qemu paths): musl-cross toolchains on PATH (any source — musl.cc prebuilt sets, distribution packages, or a local build all work; KASLD targets the standard <arch>-linux-musl-gcc triples), and qemu-<arch> user binaries on PATH (or in $QEMU_DIR). Native replay (layer 2) needs neither.
  • Layer 4: gcc + gcov, or clang + llvm-cov gcov; lcov + genhtml optional for HTML.
  • Layer 5: qemu-system-<arch> for the guest arches, plus the cross toolchains, curl, cpio. The guest kernels are fetched from Alpine automatically by tests/vm/run; the Debian/Ubuntu names are only the host package to install qemu itself (apt install qemu-system-x86 qemu-system-arm qemu-system-misc). Uses KVM automatically when the guest matches the host.
  • Layer 6: clang (or any toolchain shipping -fsanitize=fuzzer). The make fuzz target builds against libFuzzer directly; no further dependencies.
  • Layer 7: nothing mandatory — each live check note-skips when its facility (seccomp, unprivileged user namespaces, systemd --user, ≥2 CPUs) is absent.
  • extra/validate-bundle (bundle-validation tool, not a layer): jq; foreign-arch bundles also need the cross toolchains + qemu-user from layers 2–3.

CI

Per-push, .github/workflows/build.yml:

  • build job: make → make check (layer 1, including the make lint guards) → build i686 → native replay over the x86_64 + x86_32 fixtures (layer 2, no qemu) → native fixture soundness over the same (make test-fixtures equivalent, x86) → a fixture-perturbation negative control (validate-fixtures --perturb, which must reject a corrupted fixture). The job installs the full guard toolchain — gcc-i686-linux-gnu, shellcheck, jq, libxml2-utils, python3 and a -fsanitize=fuzzer clang — so all six tool-gated guards run for real rather than skipping. Steps bail on the first failure — fastest checks first.
  • cross-compile job (needs: build, so the slow emulation only runs once the fast host job passes): calls the reusable _cross-build.yml — one job per arch, fetching the cross-tools/musl-cross toolchain, running make build with a static-linkage check, then under qemu-user: the engine tests for that arch (run_test_cross → tests/test-cross <triple>, layer 3), an end-to-end crash-smoke replay of that arch's fixtures (run_replay → tests/replay, layer 2), and the fixture soundness gate (run_validate_fixtures → tests/validate-fixtures) over that arch's truth-bearing fixtures. So every push replays every arch under emulation, verifies arch-gated rule bodies, and asserts the resolved window contains the real base, not just that they compile. clang-format.yml runs independently and ungated (style, not correctness).

On-demand, .github/workflows/replay.yml:

  • Reuses _cross-build.yml with run_replay: true against a chosen toolchain_version, so each per-arch job runs tests/replay under qemu-user right after building. The per-push cross-compile job already replays every arch on the pinned toolchain; this workflow re-runs the full matrix from the Actions tab against a different cross-toolchain release, to validate a newer toolchain before bumping the pin.

.github/workflows/clang-format.yml runs the style check.

Every layer's CI status, for completeness:

layer in CI? where / why not
1 — host unit + integration + lint ✅ per-push build job (make check)
2 — end-to-end replay ✅ per-push native x86 in the build job; every foreign arch under qemu-user in the cross-compile matrix (run_replay)
3 — cross-arch engine tests ✅ per-push cross-compile matrix runs tests/test-cross per arch under qemu-user
fixture soundness (make test-fixtures) ✅ per-push native x86 in the build job; foreign arches in the cross-compile matrix under qemu-user
4 — coverage ❌ local, on-demand (make coverage); a report, not a gate
5 — live VM matrix ❌ full-system qemu with kernels outside the repo (no /dev/kvm on hosted runners); local/manual
6 — parser fuzz ❌ opt-in make fuzz; bounded fuzzing is a scheduled/local task, not a per-push gate
7 — container / cgroup ❌ opt-in make test-container; snapshots the live host and applies live restrictions, so it is not hermetic

Architecture coverage

The layers cover different arch widths, by design:

layer arches proves
cross-build + test-cross (per-push) all shipped toolchain variants, incl. float/endian (i586, armhf, armv7l, mipssf, mipselsf, powerpcle) every released binary compiles + its arch-gated rule bodies run
replay + make test-fixtures the canonical arches with a distinct code path runs on real captures / window contains the truth
tests/vm/run (live boot) same, minus the unbuildable live-kernel soundness

The float/endian variants compile the identical kasld as their base arch (armhf ≡ armv7, mipssf ≡ mips, i586 ≡ i686, powerpcle ≡ powerpc) — the cross-build matrix builds them to gate the toolchain, not new inference logic, so they carry no fixtures: the base-arch fixture already exercises every code path. Fixtures exist only where the code path genuinely differs — 32- vs 64-bit, big- vs little-endian, a per-arch header. armeb boots and captures: the big-endian arm build needs -mbe8, since the BE32 the toolchain emits by default cannot execute on a BE8 userspace.

Adding a fixture for a new arch

  1. Build a static binary: make cross (or make CC=<triple>-gcc).
  2. If the arch has no Alpine port, add a TABLE row to tests/vm/run (flavor=local) and a spec_for entry to tests/vm/build-kernel (a stock upstream defconfig plus any endianness / width overlay), then build the kernel: tests/vm/build-kernel <arch>.
  3. Capture a truth-bearing fixture from a live boot: tests/vm/run <arch> capture — reconstructs tests/fixtures/<arch>/<host>/ with host identity scrubbed.
  4. Validate: extra/validate-bundle tests/fixtures/<arch>/<host> (window ∋ truth) and tests/replay <dir> (crash-smoke).
  5. Commit the fixture — make test-fixtures and CI pick it up automatically.