review: iteration-2 fixes (fragment assertion, evidence paths, hardening)

- build-kernel.sh fragment assertion: survives a missing trailing newline
  (the read footgun, reproduced) and treats an absent symbol on a disable
  line as FATAL, symmetric with the enable arm.
- fetch-kernel-tarball.sh checks the pin before downloading; both fetchers
  add --retry-connrefused.
- mkimage rejects '.'/'..' state keys.
- ui-shot: VM liveness checked before every QMP call, console.log preserved
  as evidence on every failure path, repaint deadline widened to 90s with
  the contended-runner rationale documented.
- rs485-bridge: overflow discards back off one gap and rate-limit their log
  line, mirroring the accept-loop fix; clippy nit fixed.
- .gitignore ignores *.elf/*.map so the untracked artifacts cannot silently
  return; CI shellcheck glob now covers build/ and the rootfs boot scripts
  (directives added for the deliberate in-guest source paths).
- Docs: NPU parity row matches its sibling verification docs; line-pinned
  audit cross-references unpinned; CROSS_COMPILE documented in the build
  header; payload README lists warden-ui; ci-cd tense settled.

Verified: guards negative-tested (bad state keys, no-newline fragment);
boot smoke, portal scenario, ui-shot all PASS; 53 tests green; shellcheck
clean across the widened glob; clippy zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
This commit is contained in:
BFE Engineering
2026-08-30 08:34:47 -06:00
co-authored by Claude Fable 5
parent 2756de0b46
commit 973a414f07
17 changed files with 72 additions and 26 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ qemu_get_busybox() {
BB="${BUSYBOX:-$out/busybox-armv7l}"
if [ ! -f "$BB" ]; then
qemu_log "downloading $BB_URL"
curl --retry 3 --retry-delay 5 -fSL "$BB_URL" -o "$BB"
curl --retry 3 --retry-delay 5 --retry-connrefused -fSL "$BB_URL" -o "$BB"
fi
[ -f "$sha_file" ] || {
echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2
+3
View File
@@ -36,6 +36,9 @@ while [ $# -gt 0 ]; do
*) echo "FATAL: --state needs KEY=VALUE, got '$2'" >&2; exit 1 ;;
esac
case "${2%%=*}" in
.|..)
echo "FATAL: --state key cannot be '.' or '..'" >&2
exit 1 ;;
*[!A-Za-z0-9_.]*|'')
echo "FATAL: --state key '${2%%=*}' must match [A-Za-z0-9_.]+ (it becomes a filename)" >&2
exit 1 ;;
+4 -3
View File
@@ -19,6 +19,7 @@ Then:
cp <flare-edge>/target/armv7-unknown-linux-musleabihf/release/warden-flared qemu/payload/
```
Stage-2 init starts `warden-flared` and `warden-modbus` automatically when
present (logs land in `/tmp/<name>.log` inside the guest). An empty payload is
valid — the image boots to a busybox-only userspace.
Stage-2 init starts `warden-flared`, `warden-modbus`, and `warden-ui` (the
UI additionally needs `--display on|headless` + the virt.fragment kernel for
/dev/fb0) automatically when present (logs land in `/tmp/<name>.log` inside
the guest). An empty payload is valid — the image boots busybox-only.
+1
View File
@@ -10,6 +10,7 @@
# exits and the kernel panics; the applet-existence check below catches the
# only preventable variant of that.
# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
. /etc/warden-lib.sh
warden_populate_by_name
+1
View File
@@ -29,6 +29,7 @@ warden_populate_by_name() {
# VALIDATE it — echoes "_a" or "_b", falling back to _a with a warning.
warden_slot() {
slot="_a"
# shellcheck disable=SC2013 # cmdline TOKENS are the unit here, not lines
for tok in $(cat /proc/cmdline); do
case "$tok" in
warden.slot=*) slot="${tok#warden.slot=}" ;;
+1
View File
@@ -21,6 +21,7 @@ echo "WARDEN-QEMU-BOOT-OK"
# slot select, switch_root). It only returns on failure — then fall through to
# the diskless shell/poweroff behavior below.
if [ -b /dev/vda ]; then
# shellcheck source=qemu/rootfs/etc/rc disable=SC1091
. /etc/rc
fi
+1
View File
@@ -13,6 +13,7 @@ mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /tmp
# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
. /etc/warden-lib.sh
# Fresh devtmpfs — repopulate the by-name contract; same VALIDATED slot rule
+14 -5
View File
@@ -61,6 +61,7 @@ pub fn pump_serial(
stream.set_read_timeout(Some(gap))?;
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 256];
let mut discards: u64 = 0;
loop {
match (&*stream).read(&mut chunk) {
Ok(0) => {
@@ -72,12 +73,20 @@ pub fn pump_serial(
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > MAX_PENDING {
eprintln!(
"rs485: {} bytes buffered with no inter-frame gap — discarding \
(misbehaving master streaming continuously?)",
buf.len()
);
// Rate-limit the log and back off for one gap so a master
// streaming continuously cannot peg a core and flood
// stderr — mirroring the accept-loop backoff.
discards += 1;
if discards == 1 || discards.is_multiple_of(256) {
eprintln!(
"rs485: {} bytes buffered with no inter-frame gap — \
discarding (misbehaving master? {} discards so far)",
buf.len(),
discards
);
}
buf.clear();
std::thread::sleep(gap);
}
}
Err(e)
+20 -2
View File
@@ -94,16 +94,29 @@ sys.exit(0 if distinct > 16 else 1)
EOF
}
# The VM can die mid-poll (OOM, crash): check liveness before every QMP
# call so the failure is OUR message + console evidence, not a python
# traceback — and preserve the console log before the trap removes $WORK.
vm_alive_or_die() {
kill -0 "$QEMU_PID" 2>/dev/null && return 0
echo "FATAL: VM exited during the screendump poll" >&2
tail -25 "$WORK/console.log" >&2
mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
# Poll for the first rendered frame (bounded, no guessed sleep).
rendered=0
deadline=$((SECONDS + 90))
while [ $SECONDS -lt $deadline ]; do
vm_alive_or_die
qmp screendump "$WORK/shot1.ppm"
if frame_rendered "$WORK/shot1.ppm"; then rendered=1; break; fi
sleep 3
done
[ "$rendered" = 1 ] || {
echo "FATAL: UI never rendered a non-blank frame within 90s" >&2
mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
@@ -112,9 +125,13 @@ done
# repaint rather than guessing a delay.
qmp tap 16975 1820
changed=0
deadline=$((SECONDS + 30))
# 90s, matching the first-frame budget: TCG repaints are CPU-bound and a
# contended CI runner can be arbitrarily slower than this dev box (same
# margin reasoning as the rs485 test-gap widening).
deadline=$((SECONDS + 90))
while [ $SECONDS -lt $deadline ]; do
sleep 2
vm_alive_or_die
qmp screendump "$WORK/shot2.ppm"
if ! cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then changed=1; break; fi
done
@@ -124,7 +141,8 @@ cp "$WORK/shot1.ppm" "$OUTDIR/ui-shot1.ppm"
cp "$WORK/shot2.ppm" "$OUTDIR/ui-shot2.ppm" 2>/dev/null || true
[ "$changed" = 1 ] || {
echo "FATAL: tapping the Metrics tab did not change the frame within 30s — touch is not reaching the UI" >&2
echo "FATAL: tapping the Metrics tab did not change the frame within 90s — touch is not reaching the UI" >&2
cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
echo "tap on the Metrics tab repainted the frame (touch reached the UI)"