Skip to content

Commit 1375eec

Browse files
committed
tests: repair the fuzz harnesses and hold them to building
make fuzz failed at link for five of the six harnesses. Two causes, both drift: the rule compiled each harness as a lone translation unit, but five of them #include orchestrator.c, which reaches the estimate lattice and the quantity table in their own objects; and two cleared a global that was retired when the discard ledger replaced it, so they no longer compiled at all. Link the engine objects, and reset the ledger instead -- which is the right reset anyway, since a ledger left full makes every later iteration's caps look reached. All six now run clean over their corpora at 200,000 runs each under ASan+UBSan. Fuzz sits outside the default build graph so a missing clang stops nobody, which also means nothing in make test would ever notice the harnesses stopped compiling. check-fuzz-harnesses drives the real make fuzz rather than reassembling its command line, so it cannot pass while the target it guards fails, and skips loudly without a libFuzzer-capable compiler. Writing it exposed a third defect: FUZZ_TARGETS was a hand-maintained list, so a harness added to tests/fuzz/ was never built and the guard called the other six fine. Derive the list from the tree, and assert a binary exists for every harness present.
1 parent c10bc1a commit 1375eec

5 files changed

Lines changed: 160 additions & 13 deletions

File tree

Makefile

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,7 @@ lint :
794794
$(TEST_DIR)/check-validators \
795795
$(TEST_DIR)/check-env-docs \
796796
$(TEST_DIR)/check-shellcheck \
797+
$(TEST_DIR)/check-fuzz-harnesses \
797798
$(TEST_DIR)/check-baseline \
798799
$(TEST_DIR)/check-render-parity \
799800
$(TEST_DIR)/check-render-color \
@@ -903,13 +904,23 @@ test-engine : $(TEST_ENG_BIN)
903904
FUZZ_CC ?= clang
904905
FUZZ_CFLAGS ?= -O1 -g -fsanitize=fuzzer,address,undefined -DKASLD_TESTING -I src
905906
FUZZ_OUT := $(BUILD_DIR)/fuzz
906-
FUZZ_TARGETS := fuzz_parse_hex fuzz_capture_result fuzz_capture_scalar fuzz_parse_meta fuzz_parse_disposition fuzz_btf
907+
# Derived from the tree, not listed: a hand-maintained list is a second
908+
# inventory, and a harness missing from it is never built — which reads as
909+
# "nothing to report" rather than as a harness nobody compiles.
910+
FUZZ_TARGETS := $(patsubst tests/fuzz/%.c,%,$(wildcard tests/fuzz/fuzz_*.c))
907911
FUZZ_BINS := $(addprefix $(FUZZ_OUT)/,$(FUZZ_TARGETS))
908912

909-
$(FUZZ_OUT)/% : tests/fuzz/%.c
913+
# A harness names the parser it drives by #including the source file holding
914+
# it, so most of the program arrives through that one translation unit. What
915+
# does not is the estimate lattice and the quantity table: the orchestrator
916+
# reads both, they live in their own objects, and a harness that includes
917+
# orchestrator.c will not link without them.
918+
FUZZ_SRCS := src/estimate.c src/quantities.c
919+
920+
$(FUZZ_OUT)/% : tests/fuzz/%.c $(FUZZ_SRCS)
910921
@mkdir -p "$(FUZZ_OUT)"
911922
$(call ccv,CCLD,$@)
912-
$(Q)$(FUZZ_CC) $(FUZZ_CFLAGS) "$<" -o "$@"
923+
$(Q)$(FUZZ_CC) $(FUZZ_CFLAGS) "$<" $(FUZZ_SRCS) -o "$@"
913924

914925
.PHONY: fuzz
915926
fuzz : $(FUZZ_BINS)

docs/testing.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ stays plain, and setting `KASLD_COLOR` non-empty or empty forces either.
161161
| `check-live-probes` | every live probe (reads live kernel/CPU state) is tagged `live:1` and self-guards with `kasld_skip_live_probe()`, so it never runs offline against the analysis host |
162162
| `check-text-floor` | no component rolls its own text-base floor — they must use the `api.h` helper |
163163
| `check-shellcheck` | shellcheck over the `extra/` helper scripts |
164+
| `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 `#include`ing 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 |
164165
| `check-confidence-floor` | no engine rule pins the *guaranteed* window from a guess — a sub-floor signal may shape `likely` only, outside the reviewed allowlist |
165166
| `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 |
166167
| `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 118 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 |
@@ -187,10 +188,11 @@ stays plain, and setting `KASLD_COLOR` non-empty or empty forces either.
187188
| `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 |
188189
| `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 |
189190

190-
`check-truncation` needs `i686-linux-gnu-gcc` and `check-shellcheck` needs
191-
`shellcheck`; both **skip cleanly** (exit 0) when their tool is absent, so
192-
`make lint` works with just a host compiler. CI installs both, so there they run
193-
for real.
191+
`check-truncation` needs `i686-linux-gnu-gcc`, `check-shellcheck` needs
192+
`shellcheck`, and `check-fuzz-harnesses` needs a compiler that links
193+
`-fsanitize=fuzzer`; all three **skip cleanly** (exit 0) when their tool is
194+
absent, so `make lint` works with just a host compiler. CI installs the first
195+
two, so there they run for real.
194196

195197
---
196198

@@ -497,8 +499,9 @@ in `test_outcome` (layer 1). See `tests/container/README.md`.
497499

498500
- **Layer 1** (`make check`): a C compiler (`cc` / gcc / clang) and `make`.
499501
Nothing else for the unit tests. The `make lint` guards optionally use
500-
`i686-linux-gnu-gcc` (`check-truncation`) and `shellcheck`
501-
(`check-shellcheck`); both skip cleanly when absent.
502+
`i686-linux-gnu-gcc` (`check-truncation`), `shellcheck` (`check-shellcheck`)
503+
and a libFuzzer-capable clang (`check-fuzz-harnesses`); all skip cleanly when
504+
absent.
502505
- **Layers 2–3** (qemu paths): musl-cross toolchains on `PATH` (any source —
503506
[musl.cc](https://musl.cc/) prebuilt sets, distribution packages, or a local
504507
build all work; KASLD targets the standard `<arch>-linux-musl-gcc` triples),

tests/check-fuzz-harnesses

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/bin/sh
2+
# This file is part of KASLD - https://github.com/bcoles/kasld
3+
#
4+
# check-fuzz-harnesses — the fuzz harnesses must still build against the tree.
5+
#
6+
# A harness under tests/fuzz/ names the parser it drives by #including the
7+
# source file holding it, which makes it the only test that follows the
8+
# orchestrator's internals rather than its output. That is also how it rots:
9+
# move a global into another object, or retire one, and the harness stops
10+
# linking while every other test stays green.
11+
#
12+
# `make fuzz` is deliberately outside the default build graph, so that nobody
13+
# without clang is stopped by its absence — which also means nothing in `make
14+
# test` ever discovers that a harness stopped compiling. Five of six had been
15+
# broken for some time before this guard existed: the seed corpora, the
16+
# documentation and the security posture all described fuzzing that could not
17+
# be run.
18+
#
19+
# The check drives `make fuzz` rather than reassembling its command line, so it
20+
# cannot pass while the target it is guarding fails. It also pairs each harness
21+
# with its seed corpus: a harness with no corpus starts from nothing every run.
22+
#
23+
# Soft dependency: without a compiler that can link -fsanitize=fuzzer this
24+
# SKIPs, loudly, so a missing toolchain reads as unchecked rather than as
25+
# clean.
26+
#
27+
# Usage: tests/check-fuzz-harnesses (also run by `make lint` / `make test`)
28+
# Env: FUZZ_CC (default: clang), JOBS (parallelism for the build)
29+
# ---
30+
# <bcoles@gmail.com>
31+
32+
set -u
33+
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
34+
FUZZ_DIR=$ROOT/tests/fuzz
35+
CORPUS_DIR=$FUZZ_DIR/corpus
36+
# shellcheck source=tests/lib/guard-scope.sh
37+
. "$ROOT/tests/lib/guard-scope.sh"
38+
39+
if [ -t 1 ] || [ -n "${KASLD_COLOR:-}" ]; then
40+
RED=$(printf '\033[31m'); GREEN=$(printf '\033[32m')
41+
YELLOW=$(printf '\033[33m'); RESET=$(printf '\033[0m')
42+
else
43+
RED=; GREEN=; YELLOW=; RESET=
44+
fi
45+
46+
FUZZ_CC=${FUZZ_CC:-clang}
47+
if ! command -v "$FUZZ_CC" >/dev/null 2>&1; then
48+
printf '%scheck-fuzz-harnesses: SKIP%s (%s not installed)\n' \
49+
"$YELLOW" "$RESET" "$FUZZ_CC"
50+
exit 0
51+
fi
52+
53+
TMP=$(mktemp -d "${TMPDIR:-/tmp}/kasld-fuzz.XXXXXX") || {
54+
printf '%scheck-fuzz-harnesses: FAIL%s — mktemp\n' "$RED" "$RESET"; exit 1; }
55+
trap 'rm -rf "$TMP"' EXIT
56+
57+
# A compiler can exist without shipping libFuzzer, and the failure then lands at
58+
# link time inside the build below, where it would read as a broken harness.
59+
# Settle which it is up front, on a file that is known good.
60+
cat >"$TMP/probe.c" <<'EOF'
61+
#include <stddef.h>
62+
#include <stdint.h>
63+
int LLVMFuzzerTestOneInput(const uint8_t *d, size_t n) {
64+
(void)d;
65+
(void)n;
66+
return 0;
67+
}
68+
EOF
69+
if ! "$FUZZ_CC" -fsanitize=fuzzer,address,undefined "$TMP/probe.c" \
70+
-o "$TMP/probe" >"$TMP/probe.log" 2>&1; then
71+
printf '%scheck-fuzz-harnesses: SKIP%s (%s cannot link -fsanitize=fuzzer)\n' \
72+
"$YELLOW" "$RESET" "$FUZZ_CC"
73+
exit 0
74+
fi
75+
76+
# Every harness, and the corpus its name implies: fuzz_parse_meta.c is seeded
77+
# from corpus/parse_meta/.
78+
n=0
79+
no_corpus=
80+
for f in "$FUZZ_DIR"/fuzz_*.c; do
81+
[ -f "$f" ] || continue
82+
n=$((n + 1))
83+
base=$(basename "$f" .c)
84+
seeds=$CORPUS_DIR/${base#fuzz_}
85+
if [ ! -d "$seeds" ]; then
86+
no_corpus="$no_corpus $base"
87+
fi
88+
done
89+
90+
if [ -n "$no_corpus" ]; then
91+
printf '%scheck-fuzz-harnesses: FAIL%s — harness with no seed corpus:%s\n' \
92+
"$RED" "$RESET" "$no_corpus"
93+
printf ' expected %s/<name>/ for each tests/fuzz/fuzz_<name>.c\n' \
94+
"$CORPUS_DIR"
95+
exit 1
96+
fi
97+
98+
guard_scope "check-fuzz-harnesses" "$n" 4
99+
100+
# Build through the real target. MAKEFLAGS is cleared because this may run
101+
# under `make lint`, whose jobserver this build is not a part of.
102+
jobs=${JOBS:-$(nproc 2>/dev/null || echo 1)}
103+
if ! MAKEFLAGS='' MFLAGS='' make -C "$ROOT" -j"$jobs" fuzz \
104+
>"$TMP/build.log" 2>&1; then
105+
# The backticks quote a make target, not a command substitution.
106+
# shellcheck disable=SC2016
107+
printf '%scheck-fuzz-harnesses: FAIL%s — `make fuzz` does not build\n' \
108+
"$RED" "$RESET"
109+
sed -n '/error\|Error\|undefined/p' "$TMP/build.log" | head -20
110+
exit 1
111+
fi
112+
113+
# `make fuzz` reports success for what it chose to build; assert that is every
114+
# harness in the tree, so a harness the build never reaches cannot pass here as
115+
# one that built cleanly.
116+
missing=
117+
for f in "$FUZZ_DIR"/fuzz_*.c; do
118+
[ -f "$f" ] || continue
119+
base=$(basename "$f" .c)
120+
[ -x "$ROOT/build/fuzz/$base" ] || missing="$missing $base"
121+
done
122+
123+
if [ -n "$missing" ]; then
124+
# shellcheck disable=SC2016
125+
printf '%scheck-fuzz-harnesses: FAIL%s — `make fuzz` built no binary for:%s\n' \
126+
"$RED" "$RESET" "$missing"
127+
exit 1
128+
fi
129+
130+
printf '%scheck-fuzz-harnesses: OK%s (%d harnesses build and link, each seeded)\n' \
131+
"$GREEN" "$RESET" "$n"
132+
exit 0

tests/fuzz/fuzz_capture_result.c

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
3939
buf[i] = '\0';
4040

4141
/* Reset orchestrator state so results[] and scalar_facts[] do not grow
42-
* unbounded across iterations. Cap saturation flags too — a single fuzz
43-
* input shouldn't carry state from the previous one. */
42+
* unbounded across iterations, and clear the discard ledger with them: a
43+
* single fuzz input should not carry state from the previous one, and a
44+
* ledger left full would make every later iteration's caps look reached. */
4445
num_results = 0;
4546
num_scalar_facts = 0;
46-
orchestrator_saturation = 0;
47+
kasld_discard_reset();
4748

4849
capture_result(buf, "fuzz", 0);
4950

tests/fuzz/fuzz_capture_scalar.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
3232

3333
num_results = 0;
3434
num_scalar_facts = 0;
35-
orchestrator_saturation = 0;
35+
kasld_discard_reset();
3636

3737
capture_scalar(buf, 0);
3838

0 commit comments

Comments
 (0)