review: fix review findings (correctness, hardening, doc-accuracy)

First recursive code-review-harness pass over the authored SDK code (sim/,
config-lint, flowgen, drivers/{relays,freshness}, build/, ci.yml). Four parallel
dimension reviewers; all findings at every severity corrected per workspace rule.

Correctness / reliability:
- freshness.c min_budget_ms: use a `seen` flag, not `best==0`, as the empty
  sentinel — a zero-tolerance (max_stale_ms==0) binding was silently widened to a
  looser neighbour's budget. Regression test added; still 66/66 MC/DC.
- config-lint parse_reserved_ranges: match `reg` as a whole property token (ident
  boundary before, `=` after) so `reg-names` / a `region-*` label no longer
  mis-parses into a bogus reserved range.
- config-lint loader check: fail closed — flag any loader with a LOAD_ADDR that is
  not a known-safe boot component, instead of only known MCU names, so a future
  coprocessor ("Rtos"/"Bl32") can't slip past the 0x40000-brick gate.
- build-kernel.sh: sha256 verification is now mandatory (refuse to build if the pin
  is missing) and the mktemp scratch tree is removed on exit (trap), while a
  caller-provided WORK is left intact for CI artifact upload.

Test quality:
- freshness: added the age==max_stale boundary case and a clock-wraparound
  (now < last_ok) fail-safe-to-UNKNOWN test.
- relays: unsetenv(WARDEN_GPIO_ROOT) at main() so the NULL-env arm is hermetic.

Security / CI:
- ci.yml: top-level `permissions: contents: read` (badges overrides to write);
  pin taiki-e/install-action to commit SHA (v2.86.7).

Maintainability / docs:
- drivers/enforce-mcdc.sh: one shared, name-derived gate replaces the two
  copy-pasted per-driver scripts; Makefiles call ../../enforce-mcdc.sh.
- docs/architecture.md: §3/§4/§6/§7 rewritten to match reality — NPU/RGA models,
  config-lint, and the relays+freshness MC/DC harnesses are done; kernel §6 now
  reflects the 5.10->6.18.46 forward-port (ADR-0001), not the superseded plan44/6.6.
- README: status blurb + layout table corrected (kernel/, .github/; stale ci/ and
  patches/ descriptions fixed). hpmcu "8 tests" -> 7 in docs.
- freshness.{c,h}: ADR reference points at flare-edge ADR-0004 (warden-sdk's
  ADR-0004 is the CI runner — number collision).
- normalize rustfmt drift across sim/ + config-lint.

All green: sim 37 tests, config-lint 8 tests, both drivers 100% MC/DC (relays
40/40, freshness 66/66), clippy clean under -D warnings, gitleaks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
This commit is contained in:
BFE Engineering
2026-08-25 15:48:52 -06:00
co-authored by Claude Opus 4.8
parent 75ff711e43
commit 4ee4dfcf88
21 changed files with 375 additions and 148 deletions
+6 -1
View File
@@ -16,6 +16,11 @@ concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
# Least privilege by default: every job gets a read-only token; only `badges`
# (which commits rendered SVGs) overrides this with contents: write below.
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -34,7 +39,7 @@ jobs:
done done
- name: coverage (cargo-llvm-cov on sim) - name: coverage (cargo-llvm-cov on sim)
run: rustup component add llvm-tools-preview run: rustup component add llvm-tools-preview
- uses: taiki-e/install-action@v2 - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
with: with:
tool: cargo-llvm-cov tool: cargo-llvm-cov
- name: run coverage - name: run coverage
+5
View File
@@ -11,6 +11,11 @@ target/
*.swp *.swp
*~ *~
.DS_Store .DS_Store
__pycache__/
*.pyc
# local tooling state (code-review harness cross-session memory, etc.)
.claude/
# driver MC/DC harness build dirs # driver MC/DC harness build dirs
**/test/build/ **/test/build/
+16 -12
View File
@@ -11,11 +11,13 @@ RV1106 HMI). A from-scratch replacement for the twice-ported vendor stack
the rest of the firmware: tested, benchmarked, reproducible, and honest about the rest of the firmware: tested, benchmarked, reproducible, and honest about
what runs on real silicon versus what we simulate. what runs on real silicon versus what we simulate.
> Status: **bootstrapping.** This repo is being stood up incrementally; today it > Status: **bringup.** The hardware **simulator** and its tests, the RV1106 kernel
> hosts the hardware **simulator** and its tests. The kernel forward-port and the > forward-port as a reviewable `patches/` series, the hermetic kernel build, and two
> hermetic image build move in as each is proven. Until then, flare-edge still > Tier-1 drivers at 100% MC/DC are all in and CI-green on the `bringup` branch. What
> builds firmware from the vendored SDK + `sdk-patches/`; nothing here is on the > remains before this is on the production build path: installing the self-hosted
> production build path yet. > kernel-build runner (see `docs/ci-cd.md`) and having flare-edge consume warden-sdk
> as a dependency — both [maintainer]-gated. Until then, flare-edge still builds firmware from
> the vendored SDK + `sdk-patches/`.
## Why a new SDK ## Why a new SDK
@@ -87,13 +89,15 @@ than duplicate each other.
## Layout ## Layout
``` ```
sim/ the hardware simulator (Rust): mailbox/devmem model, HPMCU, RGA, NPU. sim/ the hardware simulator (Rust): membus/devmem, HPMCU, CRU, Modbus, RGA, NPU.
drivers/ our own hardened drivers + their seams (as they migrate in). drivers/ our own hardened drivers + their seams (relays, freshness; more migrate in).
patches/ the vendor-SDK delta (mirrors flare-edge/sdk-patches until it moves here). patches/ the RV1106 kernel forward-port delta onto pristine linux-6.18.46 (subsystem-split).
build/ the hermetic image-build wrapper (kernel → rootfs → image), incremental. kernel/ forward-port docs + provenance (rv1106-enablement/, PROVENANCE.md).
ci/ CI: patches-still-apply, host tests, coverage, benchmarks. build/ the hermetic kernel build (fetch pristine → apply patches → zImage + dtb).
docs/ architecture + ADRs (decisions/). docs/ architecture + ADRs (decisions/) + ci-cd + generated workflow flowcharts.
tools/ dev tooling. config-lint: static target-config gates (MCU-load-vs-reserved-memory — the 0x40000 brick class). tools/ dev tooling. config-lint: static target-config gates (MCU-load-vs-reserved-memory,
the 0x40000 brick class); flowgen: the workflow-flowchart generator.
.github/ CI (workflows/ci.yml): patches-apply, host tests, coverage, MC/DC, benchmarks, badges.
``` ```
## Principles ## Principles
+23 -7
View File
@@ -23,9 +23,20 @@ REPO="$(cd "$HERE/.." && pwd)"
PATCHES="$REPO/patches" PATCHES="$REPO/patches"
SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256" SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
JOBS="${JOBS:-$(nproc)}" JOBS="${JOBS:-$(nproc)}"
WORK="${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/warden-kbuild.XXXXXX")}"
URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz" URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz"
# A caller-provided WORK (e.g. CI's ${{ github.workspace }}/kbuild-out, from which
# artifacts are uploaded) is left intact; a scratch dir we mktemp'd here is our own
# (>1GB of extracted source + build output) and is removed on exit so repeated runs
# on the shared SDK box don't fill the disk.
if [ -n "${WORK:-}" ]; then
WORK_OWNED=0
else
WORK="$(mktemp -d "${TMPDIR:-/tmp}/warden-kbuild.XXXXXX")"
WORK_OWNED=1
fi
trap '[ "${WORK_OWNED:-0}" = 1 ] && rm -rf "$WORK"' EXIT
log() { printf '\033[36m== %s\033[0m\n' "$*"; } log() { printf '\033[36m== %s\033[0m\n' "$*"; }
command -v python >/dev/null || { echo "need 'python' (not python3) on PATH — SDK quirk" >&2; exit 1; } command -v python >/dev/null || { echo "need 'python' (not python3) on PATH — SDK quirk" >&2; exit 1; }
@@ -37,12 +48,17 @@ if [ ! -f "$TB" ]; then
log "downloading $URL" log "downloading $URL"
curl -fSL "$URL" -o "$TB" curl -fSL "$URL" -o "$TB"
fi fi
if [ -f "$SHA_FILE" ]; then # Fail closed: a missing pin (e.g. forgotten on a KVER bump) or a KERNEL_TARBALL
want="$(cat "$SHA_FILE")" # pointed at an arbitrary file must refuse to build, never silently skip the check
got="$(sha256sum "$TB" | awk '{print $1}')" # — the pristine tarball is the ONLY external input and integrity is the whole point.
[ "$want" = "$got" ] || { echo "tarball sha256 mismatch: want $want got $got" >&2; exit 1; } [ -f "$SHA_FILE" ] || {
log "tarball sha256 verified" echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing to build from an unverified tarball" >&2
fi exit 1
}
want="$(cat "$SHA_FILE")"
got="$(sha256sum "$TB" | awk '{print $1}')"
[ "$want" = "$got" ] || { echo "tarball sha256 mismatch: want $want got $got" >&2; exit 1; }
log "tarball sha256 verified"
# 2. extract pristine # 2. extract pristine
SRC="$WORK/linux-$KVER" SRC="$WORK/linux-$KVER"
+45 -29
View File
@@ -55,7 +55,7 @@ supervisor logic runs in CI with no panel.
- **`membus` — register/SRAM bus.** Done. `MemBus` trait + `SimBus`. - **`membus` — register/SRAM bus.** Done. `MemBus` trait + `SimBus`.
- **`hpmcu` — the RISC-V watchdog coprocessor.** Done. Faithful port of - **`hpmcu` — the RISC-V watchdog coprocessor.** Done. Faithful port of
`hpmcu/watchdog/main.c`'s state machine (boot-grace, heartbeat-timeout, disarm, `hpmcu/watchdog/main.c`'s state machine (boot-grace, heartbeat-timeout, disarm,
fire) against a `SimBus` mailbox, virtual clock, 8 tests including the fire) against a `SimBus` mailbox, virtual clock, 7 tests including the
arm-within-grace no-boot-loop safety property. This is the model that would have arm-within-grace no-boot-loop safety property. This is the model that would have
let the boot-loaded-watchdog logic be validated before the flash that bricked a let the boot-loaded-watchdog logic be validated before the flash that bricked a
bench unit (though the *layout* fault — a load address in unreserved kernel RAM — bench unit (though the *layout* fault — a load address in unreserved kernel RAM —
@@ -70,9 +70,16 @@ supervisor logic runs in CI with no panel.
and fault injection — silent-drop and forced-NAK) so `warden-modbus`'s master can be and fault injection — silent-drop and forced-NAK) so `warden-modbus`'s master can be
hardened to MC/DC against realistic device behaviour with no serial hardware. MEI hardened to MC/DC against realistic device behaviour with no serial hardware. MEI
(0x2B/0x0E) identification is the documented follow-up. (0x2B/0x0E) identification is the documented follow-up.
- **Next:** an **NPU** load model behind the path seam (deferred — no NPU feature - **`npu` — NPU load model.** Done. `NpuSim` models `/proc/rknpu/load` (the exact
ships soon); a **GPIO/relay** sysfs model (largely covered by the `WARDEN_GPIO_ROOT` "NPU load: N%" text the sysmon reads) behind the path seam, so the load-readout UI
seam in flare-edge's `tests/relays-mcdc/`); an **RGA** recording fake. is host-testable. NPU *compute* is explicitly out of scope — no inference runs here.
- **`rga` — 2D blitter offload.** Done. `RgaSim`, a recording `improcess` fake with a
programmable `IM_STATUS`, so the RGA offload-dispatch and CPU-fallback logic is
exercised behind the `#if WARDEN_USE_RGA` seam without librga; wired into the
`rga_improcess` benchmark.
- **Next:** MEI (0x2B/0x0E) Modbus identification; the Tier-2 driver *sources*
(`modbus_engine.c`, `warden_rga.c`) migrate in with the flare-edge unification
(ADR-0005) — their hardware ends are already modelled and tested above.
Integration with flare-edge: flared implements `MemBus` for `/dev/mem` and gains Integration with flare-edge: flared implements `MemBus` for `/dev/mem` and gains
`#[cfg(test)]` tests driving its real arm/beat logic against `HpmcuSim`. This needs `#[cfg(test)]` tests driving its real arm/beat logic against `HpmcuSim`. This needs
@@ -86,14 +93,15 @@ once the dependency exists. No duplication of *logic* — only the tiny trait.
"100% MC/DC on 100% of drivers" is infeasible literally: ~97% of driver LOC is "100% MC/DC on 100% of drivers" is infeasible literally: ~97% of driver LOC is
vendor blobs (AIC8800 wifi = 88.5K lines). Tiered target: vendor blobs (AIC8800 wifi = 88.5K lines). Tiered target:
- **Tier 1 — our own hardware code → real MC/DC.** modbus master (`modbus_engine.c`), - **Tier 1 — our own hardware code → real MC/DC.** Method: the proven `tests/uboot-ab`
relays (`relays.c`), the RGA wrapper's dispatch, the HPMCU supervisor pattern — extract the unit behind a small injectable seam, mock its world, build
(`hpmcu.rs`), the devmem reset ladder. Method: the proven `tests/uboot-ab` `-fcondition-coverage`, enforce with the shared `drivers/enforce-mcdc.sh` (gcc-14
pattern — extract the unit, mock its world, build `-fcondition-coverage`, enforce `gcov --conditions`) in the CI `mcdc` job. **Done here now:** `relays.c` (40/40
with `enforce-mcdc.sh` (gcc-14 `gcov --conditions`). **Gap the survey found: there conditions) and `freshness.c` (66/66), both at 100% MC/DC and CI-enforced. **Migrate
is no C-side coverage in CI at all today** — only flared line-coverage and the one in next:** the modbus master (`modbus_engine.c`) and the RGA wrapper's dispatch —
uboot-ab MC/DC file. Standing up an MC/DC harness for the first C driver their hardware ends are already modelled and tested in `sim/` (`modbus`, `rga`); the
(`relays.c` — small, safety-relevant) is the first driver-hardening deliverable. driver sources move in with the flare-edge unification (ADR-0005). The HPMCU
supervisor and devmem reset ladder are covered Rust-side (`sim/hpmcu`, `sim/cru`).
- **Tier 2 — near-mainline small drivers → branch coverage + fault injection.** - **Tier 2 — near-mainline small drivers → branch coverage + fault injection.**
- **Tier 3 — vendor blobs (AIC8800, MPP/ISP/RGA libs) → fault-injection hardening - **Tier 3 — vendor blobs (AIC8800, MPP/ISP/RGA libs) → fault-injection hardening
behind the seam,** not MC/DC. The AIC8800 SDIO-wedge Tier-1 fix + the designed behind the seam,** not MC/DC. The AIC8800 SDIO-wedge Tier-1 fix + the designed
@@ -124,24 +132,32 @@ fails against a DT with no `rtos@40000` node and passes once the reservation is
added. **Next** target-config checks: partition-table-vs-image-size and added. **Next** target-config checks: partition-table-vs-image-size and
vermagic-vs-kernel. vermagic-vs-kernel.
## 6. Kernel forward-port (separate, bounded phase) ## 6. Kernel forward-port (done — see ADR-0001)
Move to **plan44's OpenWrt RV1106 fork — Linux 6.6** (152 RV1106 patches + our A self-built **Linux 6.18.46**, forward-ported directly from the vendor 5.10.160 tree
exact board DT), not mainline (no DT/clk/display/RGA/NPU/flash-boot merged). It is onto our Buildroot LTS/uClibc base — **not** the plan44/OpenWrt 6.6 fork this section
a diff-and-borrow forward-port onto our Buildroot/uClibc base, not a swap-in originally reached for. **ADR-0001 records why that was superseded:** plan44 drops
(plan44 drops Buildroot for OpenWrt/musl and ships no AIC8800 kmod). The dominant Buildroot for OpenWrt/musl and ships no AIC8800 kmod, so it was a swap-out, not a
risk is the struct-ABI break (the VLAN saga) — mitigated by shipping any kernel forward-port. Mainline alone was not viable either (no DT/clk/display/RGA/NPU/
move as one matched boot+oem image, never a partial reflash. This phase starts flash-boot upstream for RV1106); the port reuses the already-in-mainline rv1126
after the sim + driver-hardening foundation, since those are how we'll regression register data where it matches and carries our deltas as the reviewable `patches/`
the port. series. This is **done and hardware-verified on `warden-c8a3`** — clk, pinctrl, eMMC,
GMAC, TRNG, OTP, SARADC/TSADC, RTC, USB host, PWM/backlight, VOP display, GT911 touch,
AIC8800 wifi, RGA, I2S audio, HPMCU mailbox, the open NPU driver, and PVTM all boot.
The dominant risk was the struct-ABI break (the VLAN saga), mitigated by shipping the
kernel move as one matched boot+oem image, never a partial reflash.
`build/build-kernel.sh` (the hermetic build) and the `patches-apply` CI gate keep the
series honest against pristine 6.18.46; provenance is in `patches/README.md` and
`kernel/rv1106-enablement/`.
## 7. Order of work ## 7. Order of work
1. **Simulator core**`membus` + `hpmcu` (done); reset-ladder + path-seam 1. **Simulator core**`membus`, `hpmcu`, the `cru` reset ladder, `modbus`, plus the
scaffolding next. `rga`/`npu` models. **Done.**
2. **First C-driver MC/DC harness**`relays.c`, establishing the C coverage gate. 2. **C-driver MC/DC harnesses**`relays.c` and `freshness.c` at 100% MC/DC, CI-gated
3. **flared devmem/hpmcu seam + tests** (firmware-side trait; unify with `sim/` via the shared `drivers/enforce-mcdc.sh`. **Done** (the first C coverage gate).
when the repo has a remote). 3. **flared devmem/hpmcu seam + tests** — firmware-side trait, unified with `sim/`
4. **Config-lint CI gates** (§5) — the brick-class of bug. once flare-edge consumes warden-sdk (a separate, [maintainer]-gated step). **Pending.**
5. **Hermetic image build** wrapper moves in. 4. **Config-lint CI gates** (§5) — the brick-class of bug. **Done.**
6. **Kernel 6.6 forward-port** (§6). 5. **Hermetic kernel build** (`build/build-kernel.sh` + the `patches-apply` gate). **Done.**
6. **Kernel 5.10→6.18.46 forward-port** (§6, ADR-0001). **Done** (hardware-verified).
+6 -3
View File
@@ -17,8 +17,11 @@ Self-contained logic with a clean seam, measured to **100% MC/DC** (gcc-14
**Adding a Tier-1 driver:** copy `<name>.{c,h}` here, put the hardware/OS calls behind **Adding a Tier-1 driver:** copy `<name>.{c,h}` here, put the hardware/OS calls behind
a small injectable seam, then mirror `relays/test/` (a fake backend for the logic a small injectable seam, then mirror `relays/test/` (a fake backend for the logic
branches + a real backend over a scratch tree for the plumbing) and branches + a real backend over a scratch tree for the plumbing). Reuse the shared
`enforce-mcdc.sh`. The CI job picks up any `drivers/*/test/Makefile` automatically. gate — the Makefile calls `bash ../../enforce-mcdc.sh <gcov.log> build/<name>.c.gcov
build/test.rc` (it derives the driver name from the `.gcov` file, so there is no
per-driver copy to keep in sync). The CI `mcdc` job picks up any
`drivers/*/test/Makefile` automatically.
## Tier 2 — serious testing + fault-injection + benchmarks ## Tier 2 — serious testing + fault-injection + benchmarks
@@ -30,7 +33,7 @@ is already modelled and tested here** in `../sim/`:
|---|---|---| |---|---|---|
| `modbus_engine.c` (RS485 master) | 11 pty scenarios + fault-injection + a compiled corpus walk (flare-edge `tools/modbus-sim/`, green) | `sim::modbus` RTU slave (11 tests, silent-drop/forced-NAK faults) + `modbus_read_holding` benchmark | | `modbus_engine.c` (RS485 master) | 11 pty scenarios + fault-injection + a compiled corpus walk (flare-edge `tools/modbus-sim/`, green) | `sim::modbus` RTU slave (11 tests, silent-drop/forced-NAK faults) + `modbus_read_holding` benchmark |
| `warden_rga.c` (RGA offload) | offload-dispatch + CPU-fallback logic | `sim::rga` recording `improcess` fake (programmable IM_STATUS) + `rga_improcess` benchmark | | `warden_rga.c` (RGA offload) | offload-dispatch + CPU-fallback logic | `sim::rga` recording `improcess` fake (programmable IM_STATUS) + `rga_improcess` benchmark |
| HPMCU supervisor (`hpmcu.rs`) | arm/beat/fire + boot-grace safety property | `sim::hpmcu` (8 tests) + `hpmcu_tick` benchmark | | HPMCU supervisor (`hpmcu.rs`) | arm/beat/fire + boot-grace safety property | `sim::hpmcu` (7 tests) + `hpmcu_tick` benchmark |
**Why the Tier-2 *source* isn't vendored here yet:** `modbus_engine.c` and **Why the Tier-2 *source* isn't vendored here yet:** `modbus_engine.c` and
`warden_rga.c` pull in shared UI headers (`platform.h`, `settings.h`, `lv_*`) and `warden_rga.c` pull in shared UI headers (`platform.h`, `settings.h`, `lv_*`) and
@@ -1,9 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Gate: fail unless every unit check passed AND relays.c reached 100% MC/DC # Shared Tier-1 MC/DC gate for every driver harness. Each driver's test/Makefile
# (condition) coverage with no uncovered executable lines. # invokes it as:
# $1 = gcov stdout log $2 = relays.c.gcov $3 = test exit-code file # bash ../../enforce-mcdc.sh <gcov.log> <build>/<name>.c.gcov <test.rc>
# Fails unless every unit check passed AND <name>.c reached 100% MC/DC (condition)
# coverage with no uncovered executable lines. The driver name is derived from the
# .gcov filename so this one script serves all drivers (no per-driver copies to keep
# in sync).
# $1 = gcov stdout log $2 = <name>.c.gcov $3 = test exit-code file
set -uo pipefail set -uo pipefail
LOG="$1"; GCOV="$2"; RCFILE="$3" LOG="$1"; GCOV="$2"; RCFILE="$3"
base="$(basename "$GCOV")"; base="${base%.gcov}" # e.g. freshness.c
name="${base%.c}" # e.g. freshness
rc=0 rc=0
testrc="$(cat "$RCFILE" 2>/dev/null || echo 1)" testrc="$(cat "$RCFILE" 2>/dev/null || echo 1)"
@@ -20,9 +27,10 @@ fi
notcov="$(grep -nE "condition[s]? .*not covered" "$GCOV" || true)" notcov="$(grep -nE "condition[s]? .*not covered" "$GCOV" || true)"
uncov_lines="$(grep -nE "^ +#####:" "$GCOV" || true)" uncov_lines="$(grep -nE "^ +#####:" "$GCOV" || true)"
# Summary lines for relays.c from gcov stdout. Match the exact file so # Summary lines for <name>.c from gcov stdout. Match the exact file so the test
# 'test_relays.c' (which also contains "relays.c") is NOT picked up. # harness translation unit (test_<name>.c, which also contains "<name>.c") is NOT
FMATCH="File '([^']*/)?relays[.]c'" # picked up.
FMATCH="File '([^']*/)?${name}[.]c'"
cond_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Condition outcomes covered:/{print; f=0}' "$LOG")" cond_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Condition outcomes covered:/{print; f=0}' "$LOG")"
line_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Lines executed:/{print; f=0}' "$LOG")" line_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Lines executed:/{print; f=0}' "$LOG")"
echo " ${line_line:-Lines executed: (n/a)}" echo " ${line_line:-Lines executed: (n/a)}"
@@ -34,7 +42,7 @@ if [ -n "$notcov" ]; then
rc=1 rc=1
fi fi
if [ -n "$uncov_lines" ]; then if [ -n "$uncov_lines" ]; then
echo "RESULT: uncovered executable lines in relays.c:" echo "RESULT: uncovered executable lines in ${name}.c:"
echo "$uncov_lines" | sed 's/^/ /' echo "$uncov_lines" | sed 's/^/ /'
rc=1 rc=1
fi fi
+7 -2
View File
@@ -1,5 +1,5 @@
/* /*
* The UI Freshness Contract engine (ADR 0004) — core, LVGL-free. * The UI Freshness Contract engine (flare-edge ADR-0004) — core, LVGL-free.
* See freshness.h for the contract. LVGL binding lives in freshness_lv.c. * See freshness.h for the contract. LVGL binding lives in freshness_lv.c.
*/ */
#include "freshness.h" #include "freshness.h"
@@ -175,11 +175,16 @@ uint32_t warden_fresh_count(void)
uint32_t warden_fresh_min_budget_ms(void) uint32_t warden_fresh_min_budget_ms(void)
{ {
/* `seen` — not `best == 0` — marks "nothing scanned yet", so a legitimate
* zero-tolerance binding (max_stale_ms == 0, "must be fresh every tick") wins
* the minimum instead of being mistaken for the empty sentinel and widened. */
uint32_t best = 0; uint32_t best = 0;
bool seen = false;
for(uint32_t i = 0; i < FRESH_MAX; i++) { for(uint32_t i = 0; i < FRESH_MAX; i++) {
struct warden_fresh *v = &s_vals[i]; struct warden_fresh *v = &s_vals[i];
if(v->used && v->visible && (best == 0 || v->max_stale_ms < best)) { if(v->used && v->visible && (!seen || v->max_stale_ms < best)) {
best = v->max_stale_ms; best = v->max_stale_ms;
seen = true;
} }
} }
return best; return best;
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
* The UI Freshness Contract engine (ADR 0004) — core, LVGL-free. * The UI Freshness Contract engine (flare-edge ADR-0004) — core, LVGL-free.
* *
* The panel is read on-site to judge whether hardware is healthy, so a silently * The panel is read on-site to judge whether hardware is healthy, so a silently
* *stale* number is worse than a missing one: a stale IP or hashrate reads as * *stale* number is worse than a missing one: a stale IP or hashrate reads as
+1 -1
View File
@@ -31,7 +31,7 @@ check: $(BUILD)/test
@echo "== MC/DC (condition) coverage of freshness.c ==" @echo "== MC/DC (condition) coverage of freshness.c =="
@$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../freshness.c >$(BUILD)/gcov.log 2>&1 || true @$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../freshness.c >$(BUILD)/gcov.log 2>&1 || true
@mv -f *.gcov $(BUILD)/ 2>/dev/null || true @mv -f *.gcov $(BUILD)/ 2>/dev/null || true
@bash enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/freshness.c.gcov $(BUILD)/test.rc @bash ../../enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/freshness.c.gcov $(BUILD)/test.rc
report: check report: check
@grep -nE "condition.*not covered|conditions covered" $(BUILD)/freshness.c.gcov || true @grep -nE "condition.*not covered|conditions covered" $(BUILD)/freshness.c.gcov || true
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# Gate: fail unless every unit check passed AND freshness.c reached 100% MC/DC
# (condition) coverage with no uncovered executable lines.
# $1 = gcov stdout log $2 = freshness.c.gcov $3 = test exit-code file
set -uo pipefail
LOG="$1"; GCOV="$2"; RCFILE="$3"
rc=0
testrc="$(cat "$RCFILE" 2>/dev/null || echo 1)"
if [ "$testrc" != "0" ]; then
echo "RESULT: unit checks FAILED (test exit $testrc)"; rc=1
else
echo "RESULT: all unit checks passed"
fi
if [ ! -f "$GCOV" ]; then
echo "RESULT: no coverage file ($GCOV) produced"; exit 1
fi
notcov="$(grep -nE "condition[s]? .*not covered" "$GCOV" || true)"
uncov_lines="$(grep -nE "^ +#####:" "$GCOV" || true)"
# Summary lines for freshness.c from gcov stdout. Match the exact file so
# 'test_freshness.c' (which also contains "freshness.c") is NOT picked up.
FMATCH="File '([^']*/)?freshness[.]c'"
cond_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Condition outcomes covered:/{print; f=0}' "$LOG")"
line_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Lines executed:/{print; f=0}' "$LOG")"
echo " ${line_line:-Lines executed: (n/a)}"
echo " ${cond_line:-Condition outcomes covered: (n/a)}"
if [ -n "$notcov" ]; then
echo "RESULT: MC/DC gaps (conditions not covered):"
echo "$notcov" | sed 's/^/ /'
rc=1
fi
if [ -n "$uncov_lines" ]; then
echo "RESULT: uncovered executable lines in freshness.c:"
echo "$uncov_lines" | sed 's/^/ /'
rc=1
fi
if ! echo "$cond_line" | grep -q "100.00%"; then
echo "RESULT: condition coverage is below 100%"
rc=1
fi
[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green ✓"
exit "$rc"
+29 -1
View File
@@ -44,6 +44,9 @@ static void test_decide(void) {
EXPECT(warden_fresh_decide(FRESH_UNKNOWN, false, false, 0, 100) == FRESH_RENDER_UNKNOWN); EXPECT(warden_fresh_decide(FRESH_UNKNOWN, false, false, 0, 100) == FRESH_RENDER_UNKNOWN);
EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 200, 100) == FRESH_RENDER_UNKNOWN); EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 200, 100) == FRESH_RENDER_UNKNOWN);
EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 50, 100) == FRESH_RENDER_NOCHANGE); EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 50, 100) == FRESH_RENDER_NOCHANGE);
/* boundary: age == max_stale is NOT stale (guards a `>`->`>=` regression that
* MC/DC alone would not catch — both outcomes are already covered above). */
EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 100, 100) == FRESH_RENDER_NOCHANGE);
} }
/* --- 2. bind: !produce, !render, valid, and table-full (FRESH_MAX=2) --- */ /* --- 2. bind: !produce, !render, valid, and table-full (FRESH_MAX=2) --- */
@@ -121,10 +124,18 @@ static void test_invalidate(void) {
EXPECT(g_render_calls == 1); EXPECT(g_render_calls == 1);
} }
/* --- 6. min_budget: best==0 first, then max_stale<best true and false --- */ /* --- 6. min_budget: unseen first, then max_stale<best true and false --- */
static void test_min_budget(void) { static void test_min_budget(void) {
warden_fresh_reset(); warden_fresh_reset();
EXPECT(warden_fresh_min_budget_ms() == 0); /* nothing bound */ EXPECT(warden_fresh_min_budget_ms() == 0); /* nothing bound */
/* regression (correctness review): a zero-tolerance binding (max_stale=0) must
* win the minimum, not be mistaken for the "nothing scanned" sentinel and
* widened to a looser neighbour's budget. */
warden_fresh_bind((void*)1, fake_produce, NULL, 0, "s", fake_render, (void*)9);
warden_fresh_bind((void*)1, fake_produce, NULL, 300, "s", fake_render, (void*)9);
EXPECT(warden_fresh_min_budget_ms() == 0);
warden_fresh_reset();
EXPECT(warden_fresh_min_budget_ms() == 0); /* nothing bound (post-reset) */
warden_fresh_bind((void*)1, fake_produce, NULL, 300, "s", fake_render, (void*)9); /* best=0->300 */ warden_fresh_bind((void*)1, fake_produce, NULL, 300, "s", fake_render, (void*)9); /* best=0->300 */
warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); /* 100<300 -> 100 */ warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); /* 100<300 -> 100 */
EXPECT(warden_fresh_min_budget_ms() == 100); EXPECT(warden_fresh_min_budget_ms() == 100);
@@ -160,6 +171,22 @@ static void test_false_arms(void) {
EXPECT(g_render_calls == 0); EXPECT(g_render_calls == 0);
} }
/* --- 8. clock wraparound: a uint32 ms counter wraps every ~49.7 days, so a tick
* can arrive with now_ms < last_ok_ms. The unsigned `now - last_ok_ms` then
* underflows to a huge age; the contract must fail SAFE to UNKNOWN, never
* assert the last value as if still fresh. --- */
static void test_wraparound(void) {
warden_fresh_reset();
warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9);
set_produce(FRESH_OK, "9"); reset_render();
warden_fresh_tick(1000); /* last_ok_ms = 1000, ever_ok */
EXPECT(g_render_calls == 1 && g_render_what == FRESH_RENDER_VALUE);
/* now wraps behind last_ok_ms: age underflows -> treated as stale -> UNKNOWN */
set_produce(FRESH_UNKNOWN, NULL); reset_render();
warden_fresh_tick(10);
EXPECT(g_render_calls == 1 && g_render_what == FRESH_RENDER_UNKNOWN);
}
int main(void) { int main(void) {
test_decide(); test_decide();
test_bind(); test_bind();
@@ -168,6 +195,7 @@ int main(void) {
test_invalidate(); test_invalidate();
test_min_budget(); test_min_budget();
test_false_arms(); test_false_arms();
test_wraparound();
fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail); fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail);
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+1 -1
View File
@@ -32,7 +32,7 @@ check: $(BUILD)/test
@echo "== MC/DC (condition) coverage of relays.c ==" @echo "== MC/DC (condition) coverage of relays.c =="
@$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../relays.c >$(BUILD)/gcov.log 2>&1 || true @$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../relays.c >$(BUILD)/gcov.log 2>&1 || true
@mv -f *.gcov $(BUILD)/ 2>/dev/null || true @mv -f *.gcov $(BUILD)/ 2>/dev/null || true
@bash enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/relays.c.gcov $(BUILD)/test.rc @bash ../../enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/relays.c.gcov $(BUILD)/test.rc
report: check report: check
@grep -nE "condition.*not covered|conditions covered" $(BUILD)/relays.c.gcov || true @grep -nE "condition.*not covered|conditions covered" $(BUILD)/relays.c.gcov || true
+5
View File
@@ -211,6 +211,11 @@ static void integration_tests(void)
int main(void) int main(void)
{ {
/* Hermeticity: layer 1's fake-io tests exercise gpio_root()'s `r && *r` NULL
* arm (unset env). Clear any inherited WARDEN_GPIO_ROOT so that outcome is
* deterministic instead of depending on the ambient shell (an exported value
* would otherwise drop condition coverage and spuriously fail the gate). */
unsetenv("WARDEN_GPIO_ROOT");
unit_tests(); unit_tests();
integration_tests(); integration_tests();
fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail); fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail);
+11 -2
View File
@@ -59,8 +59,17 @@ fn main() {
// RGA: one improcess dispatch record (+ clear, so the log stays bounded). // RGA: one improcess dispatch record (+ clear, so the log stays bounded).
{ {
let mut r = RgaSim::new(); let mut r = RgaSim::new();
let sf = Surface { width: 720, height: 720, format: 0 }; let sf = Surface {
let rc = Rect { x: 0, y: 0, w: 720, h: 720 }; width: 720,
height: 720,
format: 0,
};
let rc = Rect {
x: 0,
y: 0,
w: 720,
h: 720,
};
bench("rga_improcess", N, || { bench("rga_improcess", N, || {
let _ = r.improcess(sf, sf, rc, rc); let _ = r.improcess(sf, sf, rc, rc);
r.clear(); r.clear();
+5 -1
View File
@@ -203,7 +203,11 @@ mod tests {
cru.poll(0); cru.poll(0);
bus.poke32(DW_WDT_CRR, DW_WDT_PET); // pet at t=5 -> new deadline 13 bus.poke32(DW_WDT_CRR, DW_WDT_PET); // pet at t=5 -> new deadline 13
assert_eq!(cru.poll(5), None); assert_eq!(cru.poll(5), None);
assert_eq!(cru.poll(8), None, "petted: original deadline no longer applies"); assert_eq!(
cru.poll(8),
None,
"petted: original deadline no longer applies"
);
assert_eq!(cru.poll(13), Some(ResetCause::Watchdog)); assert_eq!(cru.poll(13), Some(ResetCause::Watchdog));
} }
+18 -6
View File
@@ -92,7 +92,8 @@ impl<B: MemBus> HpmcuSim<B> {
return; return;
} }
self.polls = self.polls.wrapping_add(1); self.polls = self.polls.wrapping_add(1);
self.bus.poke32(self.base + OFF_CYC, (now & 0xffff_ffff) as u32); self.bus
.poke32(self.base + OFF_CYC, (now & 0xffff_ffff) as u32);
let magic = self.bus.peek32(self.base + OFF_MAGIC); let magic = self.bus.peek32(self.base + OFF_MAGIC);
let counter = self.bus.peek32(self.base + OFF_COUNTER); let counter = self.bus.peek32(self.base + OFF_COUNTER);
let poll_lo = self.polls & 0xffff; let poll_lo = self.polls & 0xffff;
@@ -100,7 +101,8 @@ impl<B: MemBus> HpmcuSim<B> {
if magic == MAGIC_DISARM { if magic == MAGIC_DISARM {
// Deliberate stand-down: resume the moment Linux re-arms; the grace // Deliberate stand-down: resume the moment Linux re-arms; the grace
// clock restarts so a disarm-then-silence never fires. // clock restarts so a disarm-then-silence never fires.
self.bus.poke32(self.base + OFF_STATE, STATE_DISARMED | poll_lo); self.bus
.poke32(self.base + OFF_STATE, STATE_DISARMED | poll_lo);
self.t0 = now; self.t0 = now;
self.last_change = now; self.last_change = now;
} else if magic == MAGIC_ARMED { } else if magic == MAGIC_ARMED {
@@ -110,7 +112,8 @@ impl<B: MemBus> HpmcuSim<B> {
self.bus.poke32(self.base + OFF_SEEN, counter); self.bus.poke32(self.base + OFF_SEEN, counter);
self.last_change = now; self.last_change = now;
} }
self.bus.poke32(self.base + OFF_STATE, STATE_ARMED | poll_lo); self.bus
.poke32(self.base + OFF_STATE, STATE_ARMED | poll_lo);
if now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S { if now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S {
self.fire(); self.fire();
} }
@@ -186,7 +189,10 @@ mod tests {
// Heartbeat stops (counter frozen). Fires just past 90s. // Heartbeat stops (counter frozen). Fires just past 90s.
for now in (5..=90).step_by(5) { for now in (5..=90).step_by(5) {
m.tick(now); m.tick(now);
assert!(!m.fired(), "must not fire within the heartbeat window (@{now}s)"); assert!(
!m.fired(),
"must not fire within the heartbeat window (@{now}s)"
);
} }
m.tick(91); m.tick(91);
assert!(m.fired(), "must fire just past the 90s heartbeat timeout"); assert!(m.fired(), "must fire just past the 90s heartbeat timeout");
@@ -225,7 +231,10 @@ mod tests {
counter += 1; counter += 1;
arm_beat(&bus, counter); arm_beat(&bus, counter);
m.tick(now); m.tick(now);
assert!(!m.fired(), "flared armed before boot-grace: must never fire (@{now}s)"); assert!(
!m.fired(),
"flared armed before boot-grace: must never fire (@{now}s)"
);
} }
assert_eq!(m.state(), STATE_ARMED); assert_eq!(m.state(), STATE_ARMED);
} }
@@ -252,6 +261,9 @@ mod tests {
assert!(!m.fired(), "within the heartbeat window (@{now}s)"); assert!(!m.fired(), "within the heartbeat window (@{now}s)");
} }
m.tick(695); // 695 - 600 = 95 > 90 m.tick(695); // 695 - 600 = 95 > 90
assert!(m.fired(), "flared dead > heartbeat timeout: MCU fires the reset"); assert!(
m.fired(),
"flared dead > heartbeat timeout: MCU fires the reset"
);
} }
} }
+16 -4
View File
@@ -28,7 +28,11 @@ pub fn crc16(bytes: &[u8]) -> u16 {
for &b in bytes { for &b in bytes {
crc ^= b as u16; crc ^= b as u16;
for _ in 0..8 { for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 }; crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xA001
} else {
crc >> 1
};
} }
} }
crc crc
@@ -202,7 +206,11 @@ impl ModbusSlave {
if count == 0 || count > 2000 { if count == 0 || count > 2000 {
return Err(exc::ILLEGAL_DATA_VALUE); return Err(exc::ILLEGAL_DATA_VALUE);
} }
let bank = if discrete { &self.discrete } else { &self.coils }; let bank = if discrete {
&self.discrete
} else {
&self.coils
};
if start + count > bank.len() { if start + count > bank.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS); return Err(exc::ILLEGAL_DATA_ADDRESS);
} }
@@ -272,7 +280,10 @@ impl ModbusSlave {
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize; let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize; let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
let bytecount = pdu[4] as usize; let bytecount = pdu[4] as usize;
if count == 0 || count > 1968 || bytecount != count.div_ceil(8) || pdu.len() != 5 + bytecount if count == 0
|| count > 1968
|| bytecount != count.div_ceil(8)
|| pdu.len() != 5 + bytecount
{ {
return Err(exc::ILLEGAL_DATA_VALUE); return Err(exc::ILLEGAL_DATA_VALUE);
} }
@@ -361,7 +372,8 @@ mod tests {
fn coils_write_then_read() { fn coils_write_then_read() {
let mut s = ModbusSlave::new(1, 0, 16); let mut s = ModbusSlave::new(1, 0, 16);
// FC05 write single coil 3 = ON // FC05 write single coil 3 = ON
s.handle_frame(&request(1, &[0x05, 0, 3, 0xFF, 0x00])).unwrap(); s.handle_frame(&request(1, &[0x05, 0, 3, 0xFF, 0x00]))
.unwrap();
assert!(s.coil(3)); assert!(s.coil(3));
// FC01 read coils 0..8 -> bit 3 set => byte 0x08 // FC01 read coils 0..8 -> bit 3 set => byte 0x08
let resp = s.handle_frame(&request(1, &[0x01, 0, 0, 0, 8])).unwrap(); let resp = s.handle_frame(&request(1, &[0x01, 0, 0, 0, 8])).unwrap();
+12 -3
View File
@@ -20,12 +20,18 @@ pub struct NpuSim {
impl NpuSim { impl NpuSim {
/// A present NPU reporting 0% load. /// A present NPU reporting 0% load.
pub fn new() -> Self { pub fn new() -> Self {
Self { present: true, load: 0 } Self {
present: true,
load: 0,
}
} }
/// An absent NPU (rknpu.ko not loaded): `/proc/rknpu/load` does not exist. /// An absent NPU (rknpu.ko not loaded): `/proc/rknpu/load` does not exist.
pub fn absent() -> Self { pub fn absent() -> Self {
Self { present: false, load: 0 } Self {
present: false,
load: 0,
}
} }
/// Set the reported load, clamped to 0..=100. /// Set the reported load, clamped to 0..=100.
@@ -105,6 +111,9 @@ mod tests {
#[test] #[test]
fn default_is_present_idle() { fn default_is_present_idle() {
assert_eq!(NpuSim::default().proc_load(), Some("NPU load: 0%\n".to_string())); assert_eq!(
NpuSim::default().proc_load(),
Some("NPU load: 0%\n".to_string())
);
} }
} }
+25 -6
View File
@@ -52,7 +52,10 @@ pub struct RgaSim {
impl RgaSim { impl RgaSim {
/// A working RGA whose `improcess` succeeds. /// A working RGA whose `improcess` succeeds.
pub fn new() -> Self { pub fn new() -> Self {
Self { status: ImStatus::Success, blits: Vec::new() } Self {
status: ImStatus::Success,
blits: Vec::new(),
}
} }
/// Program the status `improcess` returns (set `Failed` to drive the driver's /// Program the status `improcess` returns (set `Failed` to drive the driver's
@@ -63,7 +66,12 @@ impl RgaSim {
/// Model one `improcess()` call: record it and return the programmed status. /// Model one `improcess()` call: record it and return the programmed status.
pub fn improcess(&mut self, src: Surface, dst: Surface, srect: Rect, drect: Rect) -> ImStatus { pub fn improcess(&mut self, src: Surface, dst: Surface, srect: Rect, drect: Rect) -> ImStatus {
self.blits.push(Blit { src, dst, srect, drect }); self.blits.push(Blit {
src,
dst,
srect,
drect,
});
self.status self.status
} }
@@ -99,7 +107,11 @@ mod tests {
use super::*; use super::*;
fn surf(w: i32, h: i32) -> Surface { fn surf(w: i32, h: i32) -> Surface {
Surface { width: w, height: h, format: 0 } Surface {
width: w,
height: h,
format: 0,
}
} }
fn rect(w: i32, h: i32) -> Rect { fn rect(w: i32, h: i32) -> Rect {
Rect { x: 0, y: 0, w, h } Rect { x: 0, y: 0, w, h }
@@ -108,7 +120,12 @@ mod tests {
#[test] #[test]
fn records_a_successful_blit() { fn records_a_successful_blit() {
let mut r = RgaSim::new(); let mut r = RgaSim::new();
let st = r.improcess(surf(720, 720), surf(360, 360), rect(720, 720), rect(360, 360)); let st = r.improcess(
surf(720, 720),
surf(360, 360),
rect(720, 720),
rect(360, 360),
);
assert_eq!(st, ImStatus::Success); assert_eq!(st, ImStatus::Success);
assert_eq!(r.count(), 1); assert_eq!(r.count(), 1);
let b = r.last().unwrap(); let b = r.last().unwrap();
@@ -120,8 +137,10 @@ mod tests {
fn failed_status_drives_fallback() { fn failed_status_drives_fallback() {
let mut r = RgaSim::new(); let mut r = RgaSim::new();
r.set_status(ImStatus::Failed); r.set_status(ImStatus::Failed);
assert_eq!(r.improcess(surf(10, 10), surf(10, 10), rect(10, 10), rect(10, 10)), assert_eq!(
ImStatus::Failed); r.improcess(surf(10, 10), surf(10, 10), rect(10, 10), rect(10, 10)),
ImStatus::Failed
);
// the op is still recorded — the driver dispatched it, then fell back. // the op is still recorded — the driver dispatched it, then fell back.
assert_eq!(r.count(), 1); assert_eq!(r.count(), 1);
} }
+128 -13
View File
@@ -38,6 +38,13 @@ pub struct Finding {
pub msg: String, pub msg: String,
} }
/// A byte that can appear inside a devicetree node/property identifier — used to
/// require a token boundary before a `reg` property, so substrings like `reg-names`
/// or a `region-*` node label are not matched as the `reg` property itself.
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
fn parse_addr(s: &str) -> Option<u64> { fn parse_addr(s: &str) -> Option<u64> {
let s = s.trim(); let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
@@ -47,10 +54,33 @@ fn parse_addr(s: &str) -> Option<u64> {
} }
} }
/// Does a loader entry name a coprocessor/MCU firmware (whose LOAD_ADDR matters)? /// Loaders that are part of the normal RK boot chain — DDR init, SPL/loader, the
fn is_mcu_loader(name: &str) -> bool { /// secure monitor, U-Boot — and are NOT boot-loaded coprocessor firmware, so their
/// LOAD_ADDR (if any) is the vendor boot flow, not a DRAM carve-out that must be
/// reserved. Anything NOT on this allowlist that declares a LOAD_ADDR is treated as
/// a coprocessor load and checked (**fail closed**): a future MCU named
/// "Rtos"/"Bl32"/"M0" must not slip through an MCU-*name* allowlist the way the
/// original `hpmcu`/`mcu`/`amp` substring test would have — that is exactly the
/// c8a3 0x40000-brick class this tool exists to catch.
fn is_known_safe_loader(name: &str) -> bool {
let n = name.to_ascii_lowercase(); let n = name.to_ascii_lowercase();
n.contains("hpmcu") || n.contains("mcu") || n.contains("amp") const SAFE: &[&str] = &[
"flashdata",
"flashboot",
"ddr",
"usbplug",
"spl",
"uboot",
"u-boot",
"loader",
"trust",
"tee",
"atf",
"bl31",
"fsbl",
"idblock",
];
SAFE.iter().any(|s| n.contains(s))
} }
/// Extract MCU firmware loads from an rkbin loader `.ini`: for each `LOADERn=<name>` /// Extract MCU firmware loads from an rkbin loader `.ini`: for each `LOADERn=<name>`
@@ -72,7 +102,9 @@ pub fn parse_ini_mcu_loads(ini: &str) -> Vec<McuLoad> {
section = sec.trim().to_string(); section = sec.trim().to_string();
continue; continue;
} }
let Some((k, v)) = line.split_once('=') else { continue }; let Some((k, v)) = line.split_once('=') else {
continue;
};
let (k, v) = (k.trim(), v.trim()); let (k, v) = (k.trim(), v.trim());
if section == "LOADER_OPTION" && k.to_ascii_uppercase().starts_with("LOADER") { if section == "LOADER_OPTION" && k.to_ascii_uppercase().starts_with("LOADER") {
loaders.push((k.to_ascii_uppercase(), v.to_string())); loaders.push((k.to_ascii_uppercase(), v.to_string()));
@@ -85,12 +117,19 @@ pub fn parse_ini_mcu_loads(ini: &str) -> Vec<McuLoad> {
let mut out = Vec::new(); let mut out = Vec::new();
for (loader, name) in loaders { for (loader, name) in loaders {
if !is_mcu_loader(&name) { // Fail closed: check every loader that isn't a known-safe boot component.
// A benign component without a LOAD_ADDR produces nothing (the `get` below
// misses), so this only ever surfaces loads that actually target DRAM.
if is_known_safe_loader(&name) {
continue; continue;
} }
// LOADER2 -> [LOADER2_PARAM] // LOADER2 -> [LOADER2_PARAM]
if let Some(&addr) = load_addrs.get(&format!("{loader}_PARAM")) { if let Some(&addr) = load_addrs.get(&format!("{loader}_PARAM")) {
out.push(McuLoad { loader, name, load_addr: addr }); out.push(McuLoad {
loader,
name,
load_addr: addr,
});
} }
} }
out out
@@ -129,15 +168,40 @@ pub fn parse_reserved_ranges(dt: &str) -> Vec<Range> {
} }
j += 1; j += 1;
} }
// scan reg = < a b > inside [start, j) // scan `reg = <addr size>` PROPERTY tokens inside [start, j). Match `reg` as
// a whole token — identifier boundary before it, `=` after optional space —
// so a `reg-names` property or a `region-*@…` node label (both contain the
// substring "reg") is not misparsed into a bogus range.
let block = &dt[start..j.min(dt.len())]; let block = &dt[start..j.min(dt.len())];
for reg in block.split("reg").skip(1) { let bb = block.as_bytes();
let Some(lt) = reg.find('<') else { continue }; let mut k = 0;
let Some(gt) = reg[lt..].find('>') else { continue }; while let Some(rel) = block[k..].find("reg") {
let nums: Vec<&str> = reg[lt + 1..lt + gt].split_whitespace().collect(); let p = k + rel;
let end = p + 3;
k = end; // forward progress regardless of whether this "reg" matches
let before_ok = p == 0 || !is_ident_byte(bb[p - 1]);
let mut a = end; // first non-space byte after "reg" must be '='
while a < bb.len() && matches!(bb[a], b' ' | b'\t' | b'\r' | b'\n') {
a += 1;
}
if !(before_ok && a < bb.len() && bb[a] == b'=') {
continue;
}
let Some(lt_rel) = block[a..].find('<') else {
continue;
};
let lt = a + lt_rel;
let Some(gt_rel) = block[lt..].find('>') else {
continue;
};
let gt = lt + gt_rel;
let nums: Vec<&str> = block[lt + 1..gt].split_whitespace().collect();
if nums.len() >= 2 { if nums.len() >= 2 {
if let (Some(a), Some(s)) = (parse_addr(nums[0]), parse_addr(nums[1])) { if let (Some(addr), Some(sz)) = (parse_addr(nums[0]), parse_addr(nums[1])) {
out.push(Range { start: a, size: s }); out.push(Range {
start: addr,
size: sz,
});
} }
} }
} }
@@ -245,4 +309,55 @@ FLAG=0x10007
let loads = parse_ini_mcu_loads(NONTB_INI); let loads = parse_ini_mcu_loads(NONTB_INI);
assert!(check(&loads, &parse_reserved_ranges(DT_NO_RTOS)).is_empty()); assert!(check(&loads, &parse_reserved_ranges(DT_NO_RTOS)).is_empty());
} }
// A reserving node whose LABEL and a sibling property both contain "reg", with
// an unrelated cell property (`interrupts`) right before the real `reg`.
const DT_REG_SUBSTRING: &str = r#"
reserved-memory {
#address-cells = <1>;
#size-cells = <1>;
ranges;
region-a@40000 { interrupts = <0 43 4>; reg-names = "ctrl"; reg = <0x40000 0x3c000>; no-map; };
};
"#;
/// Regression (correctness review): `reg` must match as a whole property token,
/// not a bare substring — the `region-*` label, `reg-names`, and the
/// `interrupts` cells must NOT be misparsed into extra ranges.
#[test]
fn reg_token_not_substring() {
let r = parse_reserved_ranges(DT_REG_SUBSTRING);
assert_eq!(r.len(), 1, "only the real `reg` property is a range: {r:?}");
assert_eq!(r[0].start, 0x40000);
assert_eq!(r[0].size, 0x3c000);
}
// A future coprocessor loader named nothing like hpmcu/mcu/amp, at an
// unreserved DRAM address — the exact case an MCU-name allowlist would miss.
const UNKNOWN_MCU_INI: &str = r#"
[LOADER_OPTION]
NUM=2
LOADER1=FlashData
LOADER2=Rtos
FlashData=bin/rv11/rv1106_ddr.bin
Rtos=bin/rv11/rv1106_rtos.bin
[LOADER2_PARAM]
LOAD_ADDR=0x40000
"#;
/// Regression (correctness review): fail closed. A loader that isn't a known-safe
/// boot component and declares a LOAD_ADDR is treated as a coprocessor load and
/// checked, even though its name matches no MCU keyword.
#[test]
fn unknown_coprocessor_name_is_flagged() {
let loads = parse_ini_mcu_loads(UNKNOWN_MCU_INI);
assert_eq!(
loads.len(),
1,
"the unknown loader must be checked: {loads:?}"
);
assert_eq!(loads[0].load_addr, 0x40000);
// and it's caught when no reserved-memory node covers it:
assert_eq!(check(&loads, &parse_reserved_ranges(DT_NO_RTOS)).len(), 1);
}
} }