diff --git a/.github/badges/loc.svg b/.github/badges/loc.svg index 3363c31..d0a99de 100644 --- a/.github/badges/loc.svg +++ b/.github/badges/loc.svg @@ -1 +1 @@ -lines of code: 9048lines of code9048 \ No newline at end of file +lines of code: 10180lines of code10180 \ No newline at end of file diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index c3c3f29..d4e24ba 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -tests: 46 passingtests46 passing \ No newline at end of file +tests: 53 passingtests53 passing \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9ec70b..233cb91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: | set -o pipefail : > /tmp/test.log - for d in sim tools/config-lint qemu/rs485-bridge; do + for d in sim tools/config-lint qemu/rs485-bridge qemu/tests/clockprobe; do echo "== cargo test in $d ==" | tee -a /tmp/test.log ( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log done @@ -220,6 +220,11 @@ jobs: CROSS_COMPILE: arm-linux-gnueabihf- WARDEN_CCACHE: 1 CCACHE_DIR: /home/runner/.ccache + # Matched module set for the out-of-tree wifi/BT drivers (issue #4: + # a stale 5.10 .ko was all the panel had). Full `make modules` runs + # (zImage alone emits no Module.symvers); only these dirs' .ko files + # are collected/uploaded. + WARDEN_MODULES_COLLECT: drivers/net/wireless/aic8800 # KERNEL_TARBALL is exported from the SHELL so $HOME expands — a literal # `~` in a YAML env: value is never tilde-expanded and broke every # dispatch until caught in review. @@ -246,6 +251,7 @@ jobs: path: | ${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/zImage ${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/dts/rockchip/rv1106-warden.dtb + ${{ runner.temp }}/kbuild-out/modules-out/*.ko # Short so kernel images self-expire instead of piling into the # account-wide storage quota; the prune-artifacts job above is the # active bound, this is the backstop. diff --git a/build/build-kernel.sh b/build/build-kernel.sh index 73f097c..f0caccd 100755 --- a/build/build-kernel.sh +++ b/build/build-kernel.sh @@ -19,6 +19,12 @@ # (qemu/configs/virt.fragment builds the QEMU -M virt variant); # every fragment option is verified to have taken effect # WARDEN_CCACHE=1 compile through ccache (CI caches ~/.ccache) +# WARDEN_MODULES_COLLECT +# space-separated in-tree dirs whose .ko files are wanted +# (e.g. "drivers/net/wireless/aic8800"). Runs a full +# `make modules` (the zImage target alone generates no +# Module.symvers, so per-dir M= builds cannot link) and +# collects the listed dirs' modules into $WORK/modules-out. # # Requires: `python` (not python3) on PATH — the SDK quirk; the CI runner provides # a project-local venv. Builds are SERIAL on the shared SDK box — never run two. @@ -157,6 +163,28 @@ log "building zImage + rv1106-warden.dtb (-j$JOBS)" make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" \ zImage rockchip/rv1106-warden.dtb +# Optional module set (issue #4: the panel loaded a stale 5.10 .ko because +# this build never produced matched 6.18 modules). Full `make modules` is +# required — zImage alone emits no Module.symvers, so a per-directory M= +# build cannot resolve even core symbols. FAILS CLOSED if a listed dir +# yields no modules. +if [ -n "${WARDEN_MODULES_COLLECT:-}" ]; then + MODOUT="$WORK/modules-out" + rm -rf "$MODOUT"; mkdir -p "$MODOUT" + log "building modules (full set — needed for Module.symvers)" + make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" modules + for d in $WARDEN_MODULES_COLLECT; do + [ -d "$SRC/$d" ] || { echo "FATAL: WARDEN_MODULES_COLLECT dir '$d' not in tree" >&2; exit 1; } + n=0 + while IFS= read -r ko; do + cp "$ko" "$MODOUT/"; n=$((n + 1)) + done < <(find "$SRC/$d" -name '*.ko') + [ "$n" -gt 0 ] || { echo "FATAL: no .ko produced under '$d'" >&2; exit 1; } + log "collected $n module(s) from $d" + done + ls -la "$MODOUT" +fi + Z="$SRC/arch/arm/boot/zImage" D="$SRC/arch/arm/boot/dts/rockchip/rv1106-warden.dtb" log "build OK" diff --git a/kernel/rv1106-enablement/timer/PLAN.md b/kernel/rv1106-enablement/timer/PLAN.md new file mode 100644 index 0000000..0b314f1 --- /dev/null +++ b/kernel/rv1106-enablement/timer/PLAN.md @@ -0,0 +1,54 @@ +# arch-timer / vDSO clock fix — plan (issue #3) + +Status: DIAGNOSED off-board, fix gated on two bench measurements. + +## Established (2026-08-30, qemu/ device sim) + +The same kernel family under `qemu-system-arm -M virt` gives musl +`CLOCK_MONOTONIC`/`_RAW` rate ratio 0.99963 vs `/proc/uptime` (interval +measurement, `qemu/tests/clockprobe`). The generic 6.18 armv7 vDSO is +therefore CORRECT; the board symptom (musl reads ~12% high, kernel time +right) is RV1106-specific. The boot chain runs in the secure world and is +closed rkbin — the NS view of the CPU timer registers (CNTFRQ, CNTVOFF) is +whatever it left behind, and only the arch-counter path (vDSO, +`arch_sys_counter`) trusts them. + +## What the bench must answer (single boot of the `_b` slot) + +Run `clockprobe` (interval mode) on the 6.18 slot: + +1. `ratio_mono` far from 1.0 -> RATE error: CNTFRQ wrong. The true rate = + claimed rate (dmesg `arch_timer: cp15 timer running at X MHz`) times the + measured ratio. +2. `ratio_mono` ~= 1.0 but `abs_ratio` far from 1.0 -> OFFSET error: CNTVOFF + left nonzero; rate fine. + +(The original issue measured only one absolute sample, which cannot +distinguish these.) + +## The fix (both cases, one DT override) + +Append to the BOARD dts (`rv1106-warden.dts` — never the vendor dtsi) an +override on the armv7-timer node: + + arm,cpu-registers-not-fw-configured; + clock-frequency = ; + +The property makes the driver use the physical counter, ignore CNTVOFF, and +take the frequency from DT — the documented remedy for firmware that does +not configure the CPU timer registers. `MEASURED_HZ` comes from bench +answer 1 (do NOT guess; a wrong value makes every clock wrong instead of +one path). Ship as an update to the arch/dts patch in `patches/`. + +## Regression guards + +- Off-board: `qemu/tests/clock-sanity.sh` asserts the vDSO rate in the VM + (guards the generic path; cannot see board registers). +- On-board: re-run `clockprobe` on the patched `_b` slot; both ratios and + the absolute ratio must be ~1.0. Record the numbers here and in issue #3. + +## Bench access note + +2026-08-30: c8a3 is physically dark (CP2102 console silent through two +remote power cycles; both network paths down) — needs hands at the bench +before the measurements can run. diff --git a/qemu/README.md b/qemu/README.md index a0d201b..c223a74 100644 --- a/qemu/README.md +++ b/qemu/README.md @@ -76,6 +76,21 @@ stage-2 init when present. (a 200 ms hold — an instantaneous press+release lands inside one LVGL poll and never clicks), and asserts the frame changed. `qmp.py` is the tiny QMP client. +- `ota-apply.sh ` (needs `FLARE_EDGE`) — the FULL apply loop the + portal scenario stops short of: a real signed tier-1 `.wfw` whose payload + is a bootable rootfs is pulled, verified, WRITTEN to rootfs_b + (`run.sh --allow-apply` gates it per boot), the AvbABData in `misc` is + flipped (mkimage provisions real A/B metadata), and the harness reboots + slot `_b` and asserts the applied version is running. The BCB slot CHOICE + and the physical reset stay emulated by the harness (ADR-0006 boundary); + the VM exports `WARDEN_HARD_RESET=0` so flared's post-apply reset surfaces + as a clean reported error instead of a /dev/mem fault. +- `real-image-boot.sh ` — the real-image + milestone: an ACTUAL flare-edge build (matched pair, placed by + `mkimage.sh --rootfs-image/--oem-image`) boots through its own init chain + to a getty on the VM console; real daemons start. RV1106-only init steps + degrade as documented, and binaries predating known fixes reproduce their + bugs faithfully (a feature: the VM is a time machine for field issues). - Watchdog: `run.sh --watchdog`, arm `/dev/watchdog` in the guest, don't pet — the VM resets ~30 s later (verified). Do NOT combine with a flared payload expecting survival: flared pets only while the UI heartbeat is fresh. diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh index 7b85626..47869f7 100755 --- a/qemu/mkimage.sh +++ b/qemu/mkimage.sh @@ -10,6 +10,10 @@ # sudo), then dd'd into a sparse raw image. # # Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V] +# [--rootfs-image PATH] [--oem-image PATH] +# --rootfs-image/--oem-image place REAL device images (raw ext4, e.g. a +# flare-edge build's rootfs.img/oem.img matched pair) into slot A instead of +# the busybox skeleton; slot B keeps the skeleton as a known-good fallback. # Env: # BUSYBOX path to a local busybox binary (skips the download; still verified) # OUT output dir (default: qemu/out); image at $OUT/disk.img @@ -27,6 +31,8 @@ PATH="$PATH:/usr/sbin:/sbin" PORTAL_URL="" STATE_KV=() FW_VERSION="0.0.1" +ROOTFS_IMAGE="" +OEM_IMAGE="" while [ $# -gt 0 ]; do case "$1" in --portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;; @@ -45,6 +51,8 @@ while [ $# -gt 0 ]; do esac STATE_KV+=("$2"); shift 2 ;; --fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;; + --rootfs-image) ROOTFS_IMAGE="${2:?--rootfs-image needs a path}"; shift 2 ;; + --oem-image) OEM_IMAGE="${2:?--oem-image needs a path}"; shift 2 ;; *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;; esac done @@ -90,28 +98,79 @@ mkfs_part() { DISK="$OUT/disk.img" rm -f "$DISK" +# dd a REAL raw image into a partition window, fail-closed on overflow. +place_real_image() { # $1 src image, $2 offset, $3 partition size, $4 name + local srcsz + srcsz=$(stat -c %s "$1") + [ "$srcsz" -le "$3" ] || { + echo "FATAL: $4 image $1 ($srcsz bytes) exceeds the $3-byte partition" >&2 + exit 1 + } + dd if="$1" of="$DISK" bs=4096 seek=$(($2 / 4096)) conv=notrunc,sparse status=none + qemu_log " $4: REAL image $(basename "$1") ($((srcsz / 1048576))M) @ $2" +} + place_partition() { local name="$1" off="$2" size="$3" stage="" case "$name" in - rootfs_a|rootfs_b) stage="$ROOT" ;; + rootfs_a) + if [ -n "$ROOTFS_IMAGE" ]; then + [ -f "$ROOTFS_IMAGE" ] || { echo "FATAL: --rootfs-image $ROOTFS_IMAGE not found" >&2; exit 1; } + DISK_END_TRACK "$off" "$size" + place_real_image "$ROOTFS_IMAGE" "$off" "$size" "$name" + return 0 + fi + stage="$ROOT" ;; + oem_a) + if [ -n "$OEM_IMAGE" ]; then + [ -f "$OEM_IMAGE" ] || { echo "FATAL: --oem-image $OEM_IMAGE not found" >&2; exit 1; } + DISK_END_TRACK "$off" "$size" + place_real_image "$OEM_IMAGE" "$off" "$size" "$name" + return 0 + fi + stage="$SCRATCH/empty" ;; + rootfs_b) stage="$ROOT" ;; userdata) stage="$UDATA" ;; - oem_a|oem_b) stage="$SCRATCH/empty" ;; + oem_b) stage="$SCRATCH/empty" ;; + # misc carries REAL AvbABData (byte 2048): flared's slotctl fail-closes + # on a bad magic before any OTA write, so a zeroed misc blocks apply + # scenarios. Bytes mirror flare-edge tools/mk-misc.py provisioning + # defaults (A: prio 15 successful, B: prio 14 successful, CRC32-BE). + misc) stage="__misc__" ;; # Boot-chain partitions the VM never reads: present at the right offsets, # left zeroed. Enumerated (not a wildcard) so a typo'd name in # blkdevparts.conf fails HERE, not as a confusing mount error at boot. - env|idblock|uboot|misc|boot_a|boot_b|recovery) stage="" ;; + env|idblock|uboot|boot_a|boot_b|recovery) stage="" ;; *) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;; esac + DISK_END_TRACK "$off" "$size" # dd in 4K blocks — every offset in the canonical layout is 4K-aligned; # assert rather than assume, a misaligned write would corrupt a neighbor. if [ $((off % 4096)) -ne 0 ] || [ $((size % 4096)) -ne 0 ]; then echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2 exit 1 fi - # Max, not last: blkdevparts grammar permits explicit @offsets out of order. - [ $((off + size)) -gt "$DISK_END" ] && DISK_END=$((off + size)) [ -z "$stage" ] && return 0 local img="$SCRATCH/$name.img" + if [ "$stage" = "__misc__" ]; then + python3 - "$img" "$size" <<'PYMISC' +import struct, sys, zlib +img, size = sys.argv[1], int(sys.argv[2]) +s = bytearray(28) +s[0:4] = b"\0AB0" # AB_MAGIC +s[4] = 1 # major +s[8:12] = bytes([15, 0, 1, 0]) # slot A: priority, tries, successful +s[12:16] = bytes([14, 0, 1, 0]) # slot B +meta = bytes(s) + struct.pack(">I", zlib.crc32(bytes(s)) & 0xFFFFFFFF) +buf = bytearray(size) +buf[2048:2048 + len(meta)] = meta +open(img, "wb").write(buf) +PYMISC + dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \ + conv=notrunc,sparse status=none + qemu_log " $name: AvbABData provisioned @ +2048" + return 0 + fi mkfs_part "$stage" "$size" "$img" dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \ conv=notrunc,sparse status=none @@ -119,6 +178,8 @@ place_partition() { } DISK_END=0 +# Max, not last: blkdevparts grammar permits explicit @offsets out of order. +DISK_END_TRACK() { [ $(($1 + $2)) -gt "$DISK_END" ] && DISK_END=$(($1 + $2)); return 0; } qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)" truncate -s 0 "$DISK" qemu_each_partition place_partition diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc index b6ac1e3..a991a4a 100755 --- a/qemu/rootfs/etc/rc +++ b/qemu/rootfs/etc/rc @@ -39,5 +39,12 @@ if ! command -v switch_root >/dev/null; then return 0 fi +# Hand the live devtmpfs to the new root: busybox switch_root moves nothing, +# and a REAL device rootfs's init expects /dev to already be there (its getty +# opens /dev/console immediately). Our own skeleton init remounts devtmpfs +# defensively either way. +mkdir -p /mnt/dev +mount -o move /dev /mnt/dev 2>/dev/null || mount --move /dev /mnt/dev + echo "rc: switching root to rootfs${slot} ($root)" exec switch_root /mnt /sbin/init diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init index bf5626e..ad89734 100755 --- a/qemu/rootfs/sbin/init +++ b/qemu/rootfs/sbin/init @@ -70,6 +70,16 @@ export WARDEN_FLARE_INSECURE=1 # here, and flared's /dev/mem poke dies with an external abort (SIGBUS). The # SCR1 supervisor state machine is modeled in sim/src/hpmcu.rs instead. export WARDEN_HPMCU=0 +# Same class: the CRU reset ladder's /dev/mem poke is fatal on virt. A +# post-apply "reboot" surfaces as a clean flared error; the scenario harness +# performs the actual reboot into the applied slot. +export WARDEN_HARD_RESET=0 +# OTA apply is opt-in per boot (run.sh --allow-apply): writing rootfs_b is +# safe inside disk.img but must never be the default posture. +if grep -qw warden.fwapply /proc/cmdline; then + export WARDEN_FW_ALLOW_APPLY=1 + echo "init: OTA APPLY ENABLED (warden.fwapply)" +fi for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do if [ -x "$d" ]; then name="$(basename "$d")" diff --git a/qemu/run.sh b/qemu/run.sh index a40dde5..e74e985 100755 --- a/qemu/run.sh +++ b/qemu/run.sh @@ -22,6 +22,8 @@ # --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080; 0 disables) # --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443; 0 disables) # --shell interactive shell in the guest instead of daemon hold +# --allow-apply let flared ACTUALLY apply OTA firmware (writes rootfs_b +# inside disk.img — safe in the VM, never the default) set -euo pipefail QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -30,7 +32,7 @@ QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OUT="${OUT:-$QEMU_DIR/out}" KERNEL="" INITRD="$OUT/initramfs.cpio.gz" DISK="" NO_DISK=0 SLOT="_a" -RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0 +RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0 ALLOW_APPLY=0 SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443 EXTRA=() @@ -50,6 +52,7 @@ while [ $# -gt 0 ]; do --http-port) HTTP_PORT="${2:?}"; shift 2 ;; --api-port) API_PORT="${2:?}"; shift 2 ;; --shell) SHELL_FLAG=1; shift ;; + --allow-apply) ALLOW_APPLY=1; shift ;; --) shift; EXTRA=("$@"); break ;; *) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;; esac @@ -99,6 +102,7 @@ if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then -device "virtio-blk-device,drive=vd0" ) fi [ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell" +[ "$ALLOW_APPLY" -eq 1 ] && APPEND="$APPEND warden.fwapply" [ -n "$RTC" ] && ARGS+=( -rtc "base=$RTC" ) [ "$WATCHDOG" -eq 1 ] && ARGS+=( -device i6300esb -action watchdog=reset ) [ -n "$RS485" ] && ARGS+=( -chardev "socket,id=rs485,path=$RS485,server=on,wait=off" diff --git a/qemu/tests/clock-sanity.sh b/qemu/tests/clock-sanity.sh new file mode 100755 index 0000000..c2eb5fb --- /dev/null +++ b/qemu/tests/clock-sanity.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Clock-sanity scenario (issue #3 regression guard): boot the VM, run the +# musl-static clockprobe in the guest, and assert the vDSO monotonic RATE +# matches the kernel's /proc/uptime within 1%. Under QEMU -M virt this passes +# on the current kernel (measured 0.99963) — a regression here means the +# generic vDSO path broke. The RV1106 *board* leg of issue #3 is a separate, +# bench-only measurement; this scenario cannot see board-specific CNTFRQ or +# CNTVOFF misprogramming. +# +# FAILS CLOSED on missing prerequisites. +# +# Usage: clock-sanity.sh +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/ +QDIR="$(cd "$HERE/.." && pwd)" # qemu/ + +ZIMAGE="${1:-}" +if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then + echo "FATAL: usage: $0 " >&2 + exit 1 +fi +command -v qemu-system-arm >/dev/null || { + echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2 + exit 1 +} +command -v arm-linux-gnueabihf-gcc >/dev/null || { + echo "FATAL: arm-linux-gnueabihf-gcc needed to cross-build the probe" >&2 + exit 1 +} + +# Build the probe for the guest (static musl armv7) and stage it as payload. +( cd "$HERE/clockprobe" && \ + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc \ + cargo build -q --release --target armv7-unknown-linux-musleabihf ) +install -m 0755 "$HERE/clockprobe/target/armv7-unknown-linux-musleabihf/release/clockprobe" \ + "$QDIR/payload/clockprobe" + +WORK="$(mktemp -d /tmp/wqc.XXXXXX)" +QEMU_PID="" +cleanup() { + if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi + rm -rf "$WORK" +} +trap cleanup EXIT + +bash "$QDIR/mkinitramfs.sh" +bash "$QDIR/mkimage.sh" + +for _attempt in 1 2 3; do + PORT=$((22000 + RANDOM % 20000)) + : > "$WORK/console.log" + { sleep 40; printf '/usr/bin/clockprobe\n'; sleep 16; printf 'poweroff -f\n'; sleep 8; } | \ + bash "$QDIR/run.sh" --kernel "$ZIMAGE" --shell \ + --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \ + > "$WORK/console.log" 2>&1 & + QEMU_PID=$! + sleep 3 + kill -0 "$QEMU_PID" 2>/dev/null && break + if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then + echo "== hostfwd port collision on base $PORT — retrying" + QEMU_PID="" + continue + fi + echo "FATAL: VM died at launch:" >&2 + tail -20 "$WORK/console.log" >&2 + exit 1 +done +if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then + echo "FATAL: could not launch the VM after 3 port attempts" >&2 + exit 1 +fi + +wait "$QEMU_PID" || true +grep -a 'CLOCKPROBE' "$WORK/console.log" || { + echo "FATAL: probe never ran; console tail:" >&2 + tail -25 "$WORK/console.log" >&2 + exit 1 +} + +python3 - "$WORK/console.log" <<'EOF' +import re, sys +text = open(sys.argv[1], errors="replace").read() +m = re.search(r"ratio_mono=([0-9.]+) ratio_raw=([0-9.]+)", text) +if not m: + sys.exit("FATAL: no ratio line in console output") +mono, raw = float(m.group(1)), float(m.group(2)) +ok = abs(mono - 1.0) < 0.01 and abs(raw - 1.0) < 0.01 +print(f"clock-sanity: ratio_mono={mono} ratio_raw={raw} -> {'PASS' if ok else 'FAIL'}") +sys.exit(0 if ok else 1) +EOF +echo "CLOCK-SANITY-PASS" diff --git a/qemu/tests/clockprobe/Cargo.lock b/qemu/tests/clockprobe/Cargo.lock new file mode 100644 index 0000000..663026b --- /dev/null +++ b/qemu/tests/clockprobe/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "clockprobe" +version = "0.1.0" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" diff --git a/qemu/tests/clockprobe/Cargo.toml b/qemu/tests/clockprobe/Cargo.toml new file mode 100644 index 0000000..b06b601 --- /dev/null +++ b/qemu/tests/clockprobe/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "clockprobe" +version = "0.1.0" +edition = "2021" +description = "Interval-based monotonic-clock rate probe: compares musl/vDSO CLOCK_MONOTONIC(+_RAW) against /proc/uptime so rate errors and boot-time offsets (CNTVOFF) separate cleanly. Diagnostic for issue #3." +license = "GPL-2.0-only" + +[dependencies] +libc = "0.2" + +[profile.release] +strip = true diff --git a/qemu/tests/clockprobe/src/main.rs b/qemu/tests/clockprobe/src/main.rs new file mode 100644 index 0000000..510fd0d --- /dev/null +++ b/qemu/tests/clockprobe/src/main.rs @@ -0,0 +1,25 @@ +// Differential clock probe for warden-sdk issue #3: compares the musl/vDSO +// CLOCK_MONOTONIC rate against the kernel's own /proc/uptime over a fixed +// interval, so boot-time offsets cancel and only the RATE ratio remains. +use std::{fs, thread, time::Duration}; + +fn mono(clock: libc::clockid_t) -> f64 { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + unsafe { libc::clock_gettime(clock, &mut ts) }; + ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9 +} + +fn uptime() -> f64 { + fs::read_to_string("/proc/uptime").unwrap() + .split_whitespace().next().unwrap().parse().unwrap() +} + +fn main() { + let interval = 10.0; + let (m0, r0, u0) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime()); + thread::sleep(Duration::from_secs_f64(interval)); + let (m1, r1, u1) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime()); + let (dm, dr, du) = (m1 - m0, r1 - r0, u1 - u0); + println!("CLOCKPROBE monotonic={dm:.4} raw={dr:.4} uptime={du:.4} ratio_mono={:.5} ratio_raw={:.5}", dm / du, dr / du); + println!("CLOCKPROBE abs monotonic={m1:.2} uptime={u1:.2} abs_ratio={:.5}", m1 / u1); +} diff --git a/qemu/tests/ota-apply.sh b/qemu/tests/ota-apply.sh new file mode 100755 index 0000000..973343e --- /dev/null +++ b/qemu/tests/ota-apply.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# FULL OTA apply scenario — the loop the desk e2e stops short of: the real +# flared inside the VM downloads a real signed tier-1 .wfw whose payload is a +# bootable rootfs, verifies it, and ACTUALLY WRITES rootfs_b (safe: it is a +# region inside disk.img); the harness then reboots into slot _b and asserts +# the applied firmware version is running. +# +# Documented emulation gaps (ADR-0006): the BCB slot CHOICE and the physical +# reset are performed by the harness (cmdline slot + a fresh qemu boot), not +# by U-Boot/CRU — those stay bench territory. +# +# FAILS CLOSED on missing prerequisites. +# +# Usage: ota-apply.sh +# Env: FLARE_EDGE path to a flare-edge checkout (mock portal, mk-wfw, dev key) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/ +QDIR="$(cd "$HERE/.." && pwd)" # qemu/ +# mkfs.ext4 lives in sbin (not on user PATH on Debian). +PATH="$PATH:/usr/sbin:/sbin" + +ZIMAGE="${1:-}" +if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then + echo "FATAL: usage: $0 — the virt.fragment kernel variant" >&2 + exit 1 +fi +if [ -z "${FLARE_EDGE:-}" ] || [ ! -f "$FLARE_EDGE/tools/mock-flare-portal.py" ]; then + echo "FATAL: FLARE_EDGE must point at a flare-edge checkout" >&2 + exit 1 +fi +[ -x "$QDIR/payload/warden-flared" ] || { + echo "FATAL: no qemu/payload/warden-flared (needs the WARDEN_HARD_RESET-gated build)" >&2 + exit 1 +} +command -v qemu-system-arm >/dev/null || { + echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2 + exit 1 +} + +WORK="$(mktemp -d /tmp/wqo.XXXXXX)" +QEMU_PID="" MOCK_PID="" +cleanup() { + if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi + if [ -n "$MOCK_PID" ]; then kill "$MOCK_PID" 2>/dev/null || true; fi + rm -rf "$WORK" +} +trap cleanup EXIT + +DEVICE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')" +API_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +PORT=$((20000 + RANDOM % 20000)) + +# 0. The offer's payload is a REAL bootable rootfs: the same skeleton the +# disk uses, stamped with the NEW version — booting it is the proof. +QEMU_DIR="$QDIR" +OUT="$QDIR/out" +# shellcheck source=qemu/lib.sh disable=SC1091 +. "$QDIR/lib.sh" +qemu_get_busybox +qemu_stage_rootfs "$WORK/newroot" +printf '0.0.2\n' > "$WORK/newroot/etc/warden-firmware-version" +printf 'applied-via-ota\n' > "$WORK/newroot/etc/ota-marker" +truncate -s 64M "$WORK/rootfs-payload.img" +mkfs.ext4 -F -q -d "$WORK/newroot" "$WORK/rootfs-payload.img" + +FW_SIGNING_KEY_FILE="$FLARE_EDGE/tools/testdata/fw-dev-key.seed" \ + WARDEN_KERNEL_VERSION=6.18.46 WARDEN_BUILDROOT_VERSION=2025.02 \ + WARDEN_UBOOT_VERSION=2017.09 \ + bash "$FLARE_EDGE/tools/mk-wfw.sh" "$WORK/rootfs-payload.img" 1 0.0.2 "$WORK/offer.wfw" + +# 1. mock portal offering it. +python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \ + --port "$PORT" --device "$DEVICE_ID:$API_KEY" \ + --wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 & +MOCK_PID=$! +mock_ready=0 +for _ in $(seq 1 50); do + curl -so /dev/null "http://127.0.0.1:$PORT/" && { mock_ready=1; break; } + kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; } + sleep 0.2 +done +[ "$mock_ready" = 1 ] || { echo "FATAL: mock never answered on :$PORT" >&2; exit 1; } +echo "== mock portal on :$PORT offering 0.0.2 (payload: bootable rootfs, 64M)" + +# 2. image at version 0.0.1, enrolment seeded. +bash "$QDIR/mkinitramfs.sh" +bash "$QDIR/mkimage.sh" \ + --portal-url "http://10.0.2.2:$PORT" \ + --state "flare.device_id=$DEVICE_ID" \ + --state "flare.api_key=$API_KEY" \ + --state "flare.site=qemu-devsim" \ + --fw-version 0.0.1 + +# 3. boot slot _a WITH APPLY ENABLED; flared should pull, verify, write +# rootfs_b, and surface the (gated) reboot attempt. +QEMU_PID="" +for _attempt in 1 2 3; do + VMBASE=$((20000 + RANDOM % 20000)) + : > "$WORK/console.log" + bash "$QDIR/run.sh" --kernel "$ZIMAGE" --allow-apply \ + --ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \ + > "$WORK/console.log" 2>&1 & + QEMU_PID=$! + sleep 3 + kill -0 "$QEMU_PID" 2>/dev/null && break + if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then + echo "== hostfwd port collision on base $VMBASE — retrying" + QEMU_PID="" + continue + fi + echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1 +done +if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then + echo "FATAL: could not launch the VM after 3 port attempts" >&2 + exit 1 +fi + +# 4. wait for the apply to conclude. flared logs to its in-guest file, not +# the console — but the next check-in REPORTS the outcome to the portal: +# detail "hard reset failed after slot flip" is the exact post-apply state +# under the gated reset (write done, AvbABData flipped, reboot refused). +deadline=$((SECONDS + 420)) +staged=0 +while [ $SECONDS -lt $deadline ]; do + grep -aq 'hard reset failed after slot flip' "$WORK/mock.log" && { staged=1; break; } + grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && { + echo "FATAL: guest mount failed" >&2; exit 1; } + kill -0 "$QEMU_PID" 2>/dev/null || { + echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; } + sleep 3 +done +[ "$staged" = 1 ] || { + echo "FATAL: apply never reached the post-flip state within 420s; tails:" >&2 + tail -20 "$WORK/console.log" >&2 + tail -10 "$WORK/mock.log" >&2 + exit 1 +} +grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" || { + echo "FATAL: staged without a portal asset download?!" >&2 + exit 1 +} +echo "== apply staged (asset downloaded, rootfs_b written, reset gated) — rebooting into _b" +kill "$QEMU_PID" 2>/dev/null || true +wait "$QEMU_PID" 2>/dev/null || true +QEMU_PID="" + +# 5. the harness performs the "reboot": boot slot _b, assert the OTA'd rootfs +# is what runs. +{ sleep 40; printf 'cat /etc/warden-firmware-version /etc/ota-marker\n'; sleep 3; printf 'poweroff -f\n'; sleep 8; } | \ + timeout 180 bash "$QDIR/run.sh" --kernel "$ZIMAGE" --slot _b --shell \ + --ssh-port 0 --http-port 0 --api-port 0 \ + > "$WORK/boot-b.log" 2>&1 || true +grep -aq 'WARDEN-QEMU-ROOTFS-OK slot=_b' "$WORK/boot-b.log" || { + echo "FATAL: slot _b did not boot; tail:" >&2; tail -25 "$WORK/boot-b.log" >&2; exit 1 +} +grep -aq '^0.0.2' "$WORK/boot-b.log" || { + echo "FATAL: _b is not running the applied 0.0.2 firmware" >&2 + grep -a 'warden-firmware-version' -A2 "$WORK/boot-b.log" >&2 || true + exit 1 +} +grep -aq 'applied-via-ota' "$WORK/boot-b.log" || { + echo "FATAL: OTA marker missing on _b" >&2; exit 1 +} +echo "OTA-APPLY-PASS: 0.0.1 -> 0.0.2 applied over the air and booted from slot _b" diff --git a/qemu/tests/real-image-boot.sh b/qemu/tests/real-image-boot.sh new file mode 100755 index 0000000..296e12a --- /dev/null +++ b/qemu/tests/real-image-boot.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Real-image milestone: boot an ACTUAL flare-edge build (rootfs.img + oem.img +# matched pair) in the VM on the 6.18 kernel and assert its own init chain +# reaches multi-user: the vendor rcS runs, real warden daemons start, and a +# getty answers on the console. +# +# Documented caveats (this is a fidelity milestone, not full parity): the +# RV1106-only init steps degrade on virt (backlight, goodix, npu, the 5.10 +# /oem modules fail vermagic), and binaries older than the flare-edge #106 +# fix reproduce that crash faithfully. Interactive login uses the image's own +# credentials — deliberately not recorded here. +# +# FAILS CLOSED on missing prerequisites. +# +# Usage: real-image-boot.sh +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/ +QDIR="$(cd "$HERE/.." && pwd)" # qemu/ + +ZIMAGE="${1:-}"; ROOTFS="${2:-}"; OEM="${3:-}" +for f in "$ZIMAGE" "$ROOTFS" "$OEM"; do + if [ -z "$f" ] || [ ! -f "$f" ]; then + echo "FATAL: usage: $0 — '$f' missing" >&2 + exit 1 + fi +done +command -v qemu-system-arm >/dev/null || { + echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2 + exit 1 +} + +WORK="$(mktemp -d /tmp/wqr.XXXXXX)" +QEMU_PID="" +cleanup() { + if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi + rm -rf "$WORK" +} +trap cleanup EXIT + +bash "$QDIR/mkinitramfs.sh" +bash "$QDIR/mkimage.sh" --rootfs-image "$ROOTFS" --oem-image "$OEM" + +for _attempt in 1 2 3; do + PORT=$((23000 + RANDOM % 20000)) + : > "$WORK/console.log" + bash "$QDIR/run.sh" --kernel "$ZIMAGE" \ + --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \ + > "$WORK/console.log" 2>&1 & + QEMU_PID=$! + sleep 3 + kill -0 "$QEMU_PID" 2>/dev/null && break + if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then + echo "== hostfwd port collision on base $PORT — retrying" + QEMU_PID="" + continue + fi + echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1 +done +if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then + echo "FATAL: could not launch the VM after 3 port attempts" >&2 + exit 1 +fi + +deadline=$((SECONDS + 180)) +ok_switch=0 ok_daemons=0 ok_getty=0 +while [ $SECONDS -lt $deadline ]; do + grep -aq 'rc: switching root to rootfs_a' "$WORK/console.log" && ok_switch=1 + [ "$(grep -ac 'Starting warden-' "$WORK/console.log")" -ge 2 ] && ok_daemons=1 + grep -aq 'login:' "$WORK/console.log" && ok_getty=1 + [ $ok_switch -eq 1 ] && [ $ok_daemons -eq 1 ] && [ $ok_getty -eq 1 ] && break + kill -0 "$QEMU_PID" 2>/dev/null || { + echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; } + sleep 3 +done + +fail=0 +[ $ok_switch -eq 1 ] || { echo "FAIL: never switch_rooted into the real image"; fail=1; } +[ $ok_daemons -eq 1 ] || { echo "FAIL: the image's own init never started warden daemons"; fail=1; } +[ $ok_getty -eq 1 ] || { echo "FAIL: no getty login prompt on the console"; fail=1; } +[ $fail -eq 0 ] || { tail -25 "$WORK/console.log" >&2; exit 1; } +echo "REAL-IMAGE-BOOT-PASS: the flare-edge image reached multi-user on the VM"