From c7e06514ad943324f3b9da6b680aee6f9d9b9524 Mon Sep 17 00:00:00 2001 From: BFE Engineering Date: Sat, 29 Aug 2026 19:59:44 -0600 Subject: [PATCH] qemu: RS485 bridge to sim, portal E2E scenario, CI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the device sim, all verified on QEMU 10.0.11: - qemu/rs485-bridge/: std-only crate bridging a QEMU serial chardev (unix socket) to warden_sim::ModbusSlave — gap-based RTU framing (CRC failures degrade to real-slave silence), line-protocol control socket for register seeding and fault injection (drop/exception/clear), bounds-checked so a scenario typo answers err instead of panicking the bus. 7 unit tests, bench in the sim_bench pattern. Verified end-to-end: guest master frame on /dev/ttyS4 (pci-serial) answered from the sim slave, CRC-correct. - virt machine gains highmem=off: the 32-bit non-LPAE kernel cannot reach virt's default 40-bit PCIe ECAM (pci-host-generic EOVERFLOW); with it the full PCI set probes (16550A ttyS0, i6300esb). - Watchdog scenario verified: guest arms /dev/watchdog, no petting, i6300esb resets the VM ~30s later (first environment where this arm is testable). - qemu/tests/portal-scenario.sh: the real static-musl warden-flared inside the VM against flare-edge's mock portal on the host — authenticated check-in, firmware desired-state pull, and download of a real signed tier-1 .wfw offer, asserted from the portal log. Found and filed flare-edge#106 (fatal SIGBUS in the HPMCU boot-loaded probe on non-RV1106 memory maps); runs against a flared built from the qemu-vm-support fix branch. - stage-2 init: WARDEN_FLARE_INSECURE=1 + WARDEN_HPMCU=0 (documented VM deviations), firmware-version stamp, newline-terminated state seeds. - CI: rs485-bridge joins the test loop and bench job; new qemu-tools job (shellcheck + initramfs + disk image on hosted runners); kernel-build gains a fail-closed qemu boot-smoke step. All qemu scripts shellcheck-clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K --- .github/workflows/ci.yml | 40 ++- qemu/lib.sh | 1 + qemu/mkimage.sh | 21 +- qemu/mkinitramfs.sh | 1 + qemu/rootfs/sbin/init | 7 + qemu/rs485-bridge/Cargo.lock | 14 + qemu/rs485-bridge/Cargo.toml | 26 ++ qemu/rs485-bridge/benches/bridge_bench.rs | 44 +++ qemu/rs485-bridge/src/lib.rs | 332 ++++++++++++++++++++++ qemu/rs485-bridge/src/main.rs | 105 +++++++ qemu/run.sh | 17 +- qemu/tests/boot-smoke.sh | 6 +- qemu/tests/portal-scenario.sh | 114 ++++++++ 13 files changed, 710 insertions(+), 18 deletions(-) create mode 100644 qemu/rs485-bridge/Cargo.lock create mode 100644 qemu/rs485-bridge/Cargo.toml create mode 100644 qemu/rs485-bridge/benches/bridge_bench.rs create mode 100644 qemu/rs485-bridge/src/lib.rs create mode 100644 qemu/rs485-bridge/src/main.rs create mode 100755 qemu/tests/portal-scenario.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3a7bb..e159f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: run: | set -o pipefail : > /tmp/test.log - for d in sim tools/config-lint; do + for d in sim tools/config-lint qemu/rs485-bridge; do echo "== cargo test in $d ==" | tee -a /tmp/test.log ( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log done @@ -85,6 +85,34 @@ jobs: cargo bench --locked --bench sim_bench 1> bench.txt 2> bench.json echo "== timings =="; cat bench.txt echo "== trend json =="; grep '"bench"' bench.json + - name: run rs485-bridge benchmarks + working-directory: qemu/rs485-bridge + run: | + cargo bench --locked --bench bridge_bench 1> bench.txt 2> bench.json + echo "== timings =="; cat bench.txt + echo "== trend json =="; grep '"bench"' bench.json + + qemu-tools: + # The qemu/ device-sim build tooling must stay healthy on a plain hosted + # runner: shellcheck the scripts, build the initramfs (pinned busybox, + # fail-closed sha), and build the A/B disk image (unprivileged mkfs -d). + # Booting needs a zImage and therefore lives in kernel-build's smoke step. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: shellcheck qemu scripts + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck -x qemu/*.sh qemu/tests/*.sh + - name: cache pinned busybox + uses: actions/cache@v4 + with: + path: qemu/out/busybox-armv7l + key: busybox-armv7l-${{ hashFiles('qemu/busybox.sha256') }} + - name: build initramfs + run: bash qemu/mkinitramfs.sh + - name: build A/B disk image + run: bash qemu/mkimage.sh patches-apply: # The RV1106 series must apply cleanly onto pristine linux-6.18.46. @@ -167,6 +195,16 @@ jobs: # the ephemeral Luckfox SDK checkout path. CROSS_COMPILE: arm-linux-gnueabihf- run: bash build/build-kernel.sh + # Boot smoke under QEMU: the zImage this job just built must reach the + # initramfs sentinel on -M virt (verified 2026-08-29: the canonical + # config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm — + # provisioning the runner (one-time `apt-get install qemu-system-arm` + # on bfe-mpc-0640) is part of docs/ci-cd.md, never a silent skip. + - name: boot smoke (qemu-system-arm -M virt) + run: | + bash qemu/mkinitramfs.sh + bash qemu/tests/boot-smoke.sh \ + "$RUNNER_TEMP/kbuild-out/linux-6.18.46/arch/arm/boot/zImage" # Best-effort: the build IS the gate. Uploading the zImage/dtb to GitHub # artifact storage can fail on an account-wide storage-quota hit (recalculated # every 6-12h) that has nothing to do with this build — don't red-X a good diff --git a/qemu/lib.sh b/qemu/lib.sh index e622836..cd8b2ed 100644 --- a/qemu/lib.sh +++ b/qemu/lib.sh @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Shared helpers for the qemu/ device-sim build scripts. Sourced, not executed. # Callers must run under `set -euo pipefail` and define QEMU_DIR (the qemu/ dir). diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh index 57ddd42..96036ba 100755 --- a/qemu/mkimage.sh +++ b/qemu/mkimage.sh @@ -9,14 +9,16 @@ # Built entirely UNPRIVILEGED: per-partition mkfs.ext4 -d (no loop mounts, no # sudo), then dd'd into a sparse raw image. # -# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... +# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V] # Env: # BUSYBOX path to a local busybox binary (skips the download; still verified) # OUT output dir (default: qemu/out); image at $OUT/disk.img set -euo pipefail QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=qemu/lib.sh disable=SC1091 . "$QEMU_DIR/lib.sh" +# shellcheck source=qemu/blkdevparts.conf disable=SC1091 . "$QEMU_DIR/blkdevparts.conf" OUT="${OUT:-$QEMU_DIR/out}" # mkfs.ext4 lives in sbin, which user shells on Debian don't have on PATH. @@ -24,11 +26,13 @@ PATH="$PATH:/usr/sbin:/sbin" PORTAL_URL="" STATE_KV=() +FW_VERSION="0.0.1" while [ $# -gt 0 ]; do case "$1" in --portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;; --state) STATE_KV+=("${2:?--state needs KEY=VALUE}"); shift 2 ;; - *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]...)" >&2; exit 1 ;; + --fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;; + *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;; esac done @@ -47,12 +51,17 @@ for p in "$QEMU_DIR"/payload/*; do install -m 0755 "$p" "$ROOT/usr/bin/$(basename "$p")" done +# Firmware version stamp — same path the device build writes; flared reads its +# running version here (downgrade rules key off it). +printf '%s\n' "$FW_VERSION" > "$ROOT/etc/warden-firmware-version" + # Seed persistent state (flared: one file per key under /userdata/warden). +# Newline-terminated, matching how flare-edge's fw-e2e-test.sh seeds the store. UDATA="$SCRATCH/userdata" mkdir -p "$UDATA/warden" -[ -n "$PORTAL_URL" ] && printf '%s' "$PORTAL_URL" > "$UDATA/warden/flare.url" +[ -n "$PORTAL_URL" ] && printf '%s\n' "$PORTAL_URL" > "$UDATA/warden/flare.url" for kv in ${STATE_KV[@]+"${STATE_KV[@]}"}; do - printf '%s' "${kv#*=}" > "$UDATA/warden/${kv%%=*}" + printf '%s\n' "${kv#*=}" > "$UDATA/warden/${kv%%=*}" done mkdir -p "$SCRATCH/empty" @@ -78,10 +87,10 @@ place_partition() { esac # dd in 4K blocks — every offset in the canonical layout is 4K-aligned; # assert rather than assume, a misaligned write would corrupt a neighbor. - [ $((off % 4096)) -eq 0 ] && [ $((size % 4096)) -eq 0 ] || { + 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 DISK_END=$((off + size)) [ -z "$stage" ] && return 0 local img="$SCRATCH/$name.img" diff --git a/qemu/mkinitramfs.sh b/qemu/mkinitramfs.sh index 4281527..9146188 100755 --- a/qemu/mkinitramfs.sh +++ b/qemu/mkinitramfs.sh @@ -9,6 +9,7 @@ set -euo pipefail QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=qemu/lib.sh disable=SC1091 . "$QEMU_DIR/lib.sh" OUT="${OUT:-$QEMU_DIR/out}" diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init index ac17777..481b997 100755 --- a/qemu/rootfs/sbin/init +++ b/qemu/rootfs/sbin/init @@ -62,6 +62,13 @@ hostname warden-qemu echo "WARDEN-QEMU-ROOTFS-OK slot=${slot}" # Payload daemons (dropped into /usr/bin by qemu/mkimage.sh from qemu/payload/). +# WARDEN_FLARE_INSECURE=1: the VM's portal is the desk mock over plain HTTP. +# This is a dev instrument — a production device build never sets it. +export WARDEN_FLARE_INSECURE=1 +# No HPMCU on -M virt: the mailbox SRAM (0xff6fff00) is unmapped bus space +# 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 for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do if [ -x "$d" ]; then name="$(basename "$d")" diff --git a/qemu/rs485-bridge/Cargo.lock b/qemu/rs485-bridge/Cargo.lock new file mode 100644 index 0000000..769b4fe --- /dev/null +++ b/qemu/rs485-bridge/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "warden-rs485-bridge" +version = "0.1.0" +dependencies = [ + "warden-sim", +] + +[[package]] +name = "warden-sim" +version = "0.1.0" diff --git a/qemu/rs485-bridge/Cargo.toml b/qemu/rs485-bridge/Cargo.toml new file mode 100644 index 0000000..e2df286 --- /dev/null +++ b/qemu/rs485-bridge/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "warden-rs485-bridge" +version = "0.1.0" +edition = "2021" +description = "Bridges a QEMU serial chardev (unix socket) to the warden-sim Modbus RTU slave, so the VM guest's RS-485 master polls the same simulated field bus the unit tests do — fault injection included." +license = "MIT OR Apache-2.0" + +[lib] +name = "warden_rs485_bridge" +path = "src/lib.rs" + +[[bin]] +name = "rs485-bridge" +path = "src/main.rs" + +# Std-only on purpose: the sim crate it wraps is zero-dep, and unix sockets + +# read timeouts need nothing external. +[dependencies] +warden-sim = { path = "../../sim" } + +[dev-dependencies] + +# Dependency-free micro-benchmarks, same pattern as sim/benches/sim_bench.rs. +[[bench]] +name = "bridge_bench" +harness = false diff --git a/qemu/rs485-bridge/benches/bridge_bench.rs b/qemu/rs485-bridge/benches/bridge_bench.rs new file mode 100644 index 0000000..b8f8fd2 --- /dev/null +++ b/qemu/rs485-bridge/benches/bridge_bench.rs @@ -0,0 +1,44 @@ +//! Micro-benchmarks for the RS-485 bridge dispatch path — same dependency-free +//! fixed-iteration pattern as sim/benches/sim_bench.rs: human timings to +//! stdout, one JSON line per benchmark to stderr for CI trend capture. +//! +//! Run: `cargo bench` (or `cargo run --release --bench bridge_bench`). + +use std::time::Instant; +use warden_rs485_bridge::{handle_control_line, Bus}; +use warden_sim::modbus::read_holding; + +fn bench(name: &str, iters: u64, mut f: F) { + for _ in 0..(iters / 10).max(1) { + f(); // warm up + } + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let ns = t.elapsed().as_nanos() as f64 / iters as f64; + println!("{name:<24} {ns:>9.1} ns/op ({iters} iters)"); + eprintln!("{{\"bench\":\"{name}\",\"ns_per_op\":{ns:.1},\"iters\":{iters}}}"); +} + +fn main() { + const N: u64 = 1_000_000; + + // Full request->reply dispatch through the locked slave (the per-poll cost + // a guest master pays on the simulated bus, minus socket I/O). + { + let bus = Bus::new(1, 128, 64); + let req = read_holding(1, 2, 4); + bench("bridge_dispatch", N, || { + let _ = bus.slave.lock().unwrap().handle_frame(&req); + }); + } + + // Control-channel command parse + register write. + { + let bus = Bus::new(1, 128, 64); + bench("control_line", N, || { + let _ = handle_control_line("holding 5=1234", &bus); + }); + } +} diff --git a/qemu/rs485-bridge/src/lib.rs b/qemu/rs485-bridge/src/lib.rs new file mode 100644 index 0000000..92023ad --- /dev/null +++ b/qemu/rs485-bridge/src/lib.rs @@ -0,0 +1,332 @@ +//! Bridge a QEMU serial chardev (unix socket) to `warden_sim::ModbusSlave`. +//! +//! The guest side is the *master* (flare-edge's `warden-modbus` scanner, polling +//! what it believes is /dev/ttyS4); this bridge is the wire and every slave on +//! it. Frames are delimited by an inter-frame gap of silence: RTU's 3.5-char +//! rule cannot survive a socket transport, so a wall-clock gap stands in for it. +//! A mis-split frame fails CRC inside `handle_frame`, which answers `None` — +//! exactly a real slave staying silent — and the master already treats silence +//! as a timeout, so the failure mode degrades to a dropped poll, never a +//! phantom reply. +//! +//! A second unix socket (the control channel) scripts the simulated bus from +//! test harnesses: fault injection (`drop`, `exception`, `clear`) and register +//! seeding/reading, one command per line. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::sync::Mutex; +use std::time::Duration; +use warden_sim::ModbusSlave; + +/// Default inter-frame gap. Generous next to real RTU (3.5 chars at 9600 baud +/// is ~4 ms) because a loaded host can stall a reader; the guest master's +/// response timeout is orders of magnitude larger. +pub const DEFAULT_GAP: Duration = Duration::from_millis(10); + +/// The shared bus: the slave plus its declared dimensions. The sim's register +/// setters panic on out-of-range indices (deliberate test-harness semantics); +/// the control channel must bounds-check first so a typo in a scenario script +/// answers `err` instead of killing the VM's whole field bus. +pub struct Bus { + pub slave: Mutex, + pub regs: usize, + pub bits: usize, +} + +impl Bus { + pub fn new(address: u8, regs: usize, bits: usize) -> Self { + Bus { + slave: Mutex::new(ModbusSlave::new(address, regs, bits)), + regs, + bits, + } + } +} + +/// Pump one serial connection until EOF: accumulate bytes, dispatch a frame to +/// the slave after `gap` of silence, write back the reply when the slave +/// answers. Any pending bytes are dispatched on EOF so a final unflushed frame +/// is not lost. +pub fn pump_serial( + stream: &UnixStream, + slave: &Mutex, + gap: Duration, +) -> std::io::Result<()> { + stream.set_read_timeout(Some(gap))?; + let mut buf: Vec = Vec::new(); + let mut chunk = [0u8; 256]; + loop { + match (&*stream).read(&mut chunk) { + Ok(0) => { + if !buf.is_empty() { + dispatch(&mut buf, stream, slave)?; + } + return Ok(()); + } + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + if !buf.is_empty() { + dispatch(&mut buf, stream, slave)?; + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } +} + +fn dispatch( + buf: &mut Vec, + stream: &UnixStream, + slave: &Mutex, +) -> std::io::Result<()> { + let reply = slave.lock().unwrap().handle_frame(buf); + match &reply { + Some(r) => eprintln!("rs485: {} -> {}", hex(buf), hex(r)), + None => eprintln!("rs485: {} -> (silence)", hex(buf)), + } + buf.clear(); + if let Some(r) = reply { + (&*stream).write_all(&r)?; + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join("") +} + +/// Execute one control-channel command against the slave. One command per +/// line; the reply is `ok`, `ok `, or `err `. +/// +/// drop answer the next n requests with silence +/// exception NAK everything with this exception code (0x01..) +/// clear clear injected faults +/// holding = seed a holding register +/// input = seed an input register +/// coil =<0|1> seed a coil +/// discrete =<0|1> seed a discrete input +/// get-holding read a holding register back +/// get-coil read a coil back +/// ping liveness check +pub fn handle_control_line(line: &str, bus: &Bus) -> String { + let mut words = line.split_whitespace(); + let cmd = match words.next() { + Some(c) => c, + None => return "err empty command".into(), + }; + let arg = words.next(); + if words.next().is_some() { + return format!("err trailing arguments after '{cmd}'"); + } + let bound = |cmd: &str| match cmd { + "holding" | "input" | "get-holding" => bus.regs, + _ => bus.bits, + }; + let mut s = bus.slave.lock().unwrap(); + match (cmd, arg) { + ("ping", None) => "ok".into(), + ("clear", None) => { + s.clear_faults(); + "ok".into() + } + ("drop", Some(n)) => match n.parse::() { + Ok(n) => { + s.drop_next(n); + "ok".into() + } + Err(_) => format!("err bad count '{n}'"), + }, + ("exception", Some(c)) => match parse_u16(c) { + Some(c) if c <= 0xff => { + s.force_exception(c as u8); + "ok".into() + } + _ => format!("err bad exception code '{c}'"), + }, + ("holding" | "input" | "coil" | "discrete", Some(kv)) => { + let (addr, val) = match kv.split_once('=') { + Some((a, v)) => (parse_u16(a), parse_u16(v)), + None => (None, None), + }; + match (addr, val) { + (Some(a), _) if (a as usize) >= bound(cmd) => { + format!("err address {a} out of range (0..{})", bound(cmd)) + } + (Some(a), Some(v)) => { + match cmd { + "holding" => s.set_holding(a as usize, v), + "input" => s.set_input(a as usize, v), + "coil" => s.set_coil(a as usize, v != 0), + _ => s.set_discrete(a as usize, v != 0), + } + "ok".into() + } + _ => format!("err expected =, got '{kv}'"), + } + } + ("get-holding" | "get-coil", Some(a)) => match parse_u16(a) { + Some(a) if (a as usize) >= bound(cmd) => { + format!("err address {a} out of range (0..{})", bound(cmd)) + } + Some(a) if cmd == "get-holding" => format!("ok {}", s.holding(a as usize)), + Some(a) => format!("ok {}", u8::from(s.coil(a as usize))), + None => format!("err bad address '{a}'"), + }, + _ => format!("err unknown or malformed command '{line}'"), + } +} + +fn parse_u16(s: &str) -> Option { + if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u16::from_str_radix(h, 16).ok() + } else { + s.parse().ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + use warden_sim::modbus::{crc_ok, read_holding}; + + // Test gap is larger than DEFAULT_GAP so a loaded CI runner cannot split + // a frame that the test wrote in two deliberate chunks. + const GAP: Duration = Duration::from_millis(25); + const SETTLE: Duration = Duration::from_millis(100); + + fn bus() -> Bus { + let b = Bus::new(1, 16, 16); + b.slave.lock().unwrap().set_holding(2, 0xbeef); + b + } + + fn with_pump(bus: &Bus, f: F) { + let (master, wire) = UnixStream::pair().unwrap(); + thread::scope(|sc| { + sc.spawn(|| pump_serial(&wire, &bus.slave, GAP).unwrap()); + f(&master); + master.shutdown(std::net::Shutdown::Both).unwrap(); + }); + } + + fn read_reply(master: &UnixStream) -> Vec { + master.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut buf = [0u8; 256]; + let n = (&*master).read(&mut buf).expect("expected a reply frame"); + buf[..n].to_vec() + } + + #[test] + fn whole_frame_gets_a_valid_reply() { + let s = bus(); + with_pump(&s, |master| { + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let reply = read_reply(master); + assert!(crc_ok(&reply), "reply must carry a valid CRC"); + // addr, fc, byte count, 0xbeef + assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]); + }); + } + + #[test] + fn frame_split_across_writes_within_gap_is_one_frame() { + let s = bus(); + with_pump(&s, |master| { + let req = read_holding(1, 2, 1); + let (a, b) = req.split_at(3); + (&*master).write_all(a).unwrap(); + thread::sleep(Duration::from_millis(2)); // well inside GAP + (&*master).write_all(b).unwrap(); + let reply = read_reply(master); + assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]); + }); + } + + #[test] + fn two_frames_separated_by_gap_get_two_replies() { + let s = bus(); + with_pump(&s, |master| { + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let first = read_reply(master); + assert_eq!(&first[..5], &[1, 0x03, 2, 0xbe, 0xef]); + thread::sleep(SETTLE); + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let second = read_reply(master); + assert_eq!(second, first); + }); + } + + #[test] + fn injected_drop_is_silence_then_recovery() { + let s = bus(); + assert_eq!(handle_control_line("drop 1", &s), "ok"); + with_pump(&s, |master| { + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + master + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let mut buf = [0u8; 16]; + assert!( + (&*master).read(&mut buf).is_err(), + "dropped request must produce silence" + ); + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let reply = read_reply(master); + assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]); + }); + } + + #[test] + fn control_seeds_and_reads_registers() { + let s = bus(); + assert_eq!(handle_control_line("holding 5=1234", &s), "ok"); + assert_eq!(handle_control_line("get-holding 5", &s), "ok 1234"); + assert_eq!(handle_control_line("coil 3=1", &s), "ok"); + assert_eq!(handle_control_line("get-coil 3", &s), "ok 1"); + assert_eq!(handle_control_line("holding 0xF=0xff", &s), "ok"); + assert_eq!(handle_control_line("get-holding 15", &s), "ok 255"); + // Out of range must answer err, never panic the bus (16-reg slave). + assert!(handle_control_line("holding 0x10=0xff", &s).starts_with("err")); + assert!(handle_control_line("get-holding 16", &s).starts_with("err")); + assert!(handle_control_line("coil 16=1", &s).starts_with("err")); + assert_eq!(handle_control_line("ping", &s), "ok"); + } + + #[test] + fn control_rejects_malformed_lines() { + let s = bus(); + assert!(handle_control_line("", &s).starts_with("err")); + assert!(handle_control_line("drop many", &s).starts_with("err")); + assert!(handle_control_line("holding 5", &s).starts_with("err")); + assert!(handle_control_line("exception 300", &s).starts_with("err")); + assert!(handle_control_line("frobnicate 1", &s).starts_with("err")); + assert!(handle_control_line("drop 1 2", &s).starts_with("err")); + } + + #[test] + fn forced_exception_naks_and_clear_recovers() { + let s = bus(); + assert_eq!(handle_control_line("exception 0x02", &s), "ok"); + with_pump(&s, |master| { + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let nak = read_reply(master); + assert_eq!(&nak[..3], &[1, 0x83, 0x02], "fc|0x80 + exception code"); + assert_eq!(handle_control_line("clear", &s), "ok"); + thread::sleep(SETTLE); + (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); + let reply = read_reply(master); + assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]); + }); + } +} diff --git a/qemu/rs485-bridge/src/main.rs b/qemu/rs485-bridge/src/main.rs new file mode 100644 index 0000000..f9ac955 --- /dev/null +++ b/qemu/rs485-bridge/src/main.rs @@ -0,0 +1,105 @@ +//! CLI wiring for the RS-485 bridge. All behavior lives in the lib (tested +//! there); this file only parses arguments, connects sockets, and spawns the +//! control listener. +//! +//! Typical use (matches qemu/run.sh --rs485): +//! +//! qemu/run.sh --kernel ... --rs485 /tmp/warden-rs485.sock & +//! rs485-bridge --serial /tmp/warden-rs485.sock --control /tmp/warden-rs485-ctl.sock + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::process::exit; +use std::time::{Duration, Instant}; +use warden_rs485_bridge::{handle_control_line, pump_serial, Bus, DEFAULT_GAP}; + +fn usage() -> ! { + eprintln!( + "usage: rs485-bridge --serial [--control ] [--address N] \ + [--regs N] [--bits N] [--gap-ms N]" + ); + exit(2); +} + +fn main() { + let mut serial: Option = None; + let mut control: Option = None; + let mut address: u8 = 1; + let mut regs: usize = 128; + let mut bits: usize = 64; + let mut gap = DEFAULT_GAP; + + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let mut val = |name: &str| args.next().unwrap_or_else(|| { + eprintln!("{name} needs a value"); + usage() + }); + match a.as_str() { + "--serial" => serial = Some(val("--serial")), + "--control" => control = Some(val("--control")), + "--address" => address = val("--address").parse().unwrap_or_else(|_| usage()), + "--regs" => regs = val("--regs").parse().unwrap_or_else(|_| usage()), + "--bits" => bits = val("--bits").parse().unwrap_or_else(|_| usage()), + "--gap-ms" => { + gap = Duration::from_millis(val("--gap-ms").parse().unwrap_or_else(|_| usage())) + } + _ => usage(), + } + } + let serial = serial.unwrap_or_else(|| usage()); + + // The bus is shared between the serial pump and the control channel. + // 'static so the control thread needs no scoped lifetime: the bridge runs + // until killed. + let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits))); + + if let Some(path) = control { + let _ = std::fs::remove_file(&path); // stale socket from a previous run + let listener = UnixListener::bind(&path).unwrap_or_else(|e| { + eprintln!("FATAL: cannot bind control socket {path}: {e}"); + exit(1); + }); + eprintln!("rs485: control socket at {path}"); + std::thread::spawn(move || { + for conn in listener.incoming().flatten() { + let reader = BufReader::new(conn.try_clone().expect("clone control conn")); + let mut writer = conn; + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + let reply = handle_control_line(&line, bus); + if writeln!(writer, "{reply}").is_err() { + break; + } + } + } + }); + } + + // QEMU (chardev server=on) may come up after us: retry the connect briefly + // instead of racing the VM launch. + let deadline = Instant::now() + Duration::from_secs(15); + let stream = loop { + match UnixStream::connect(&serial) { + Ok(s) => break s, + Err(e) if Instant::now() < deadline => { + eprintln!("rs485: waiting for {serial} ({e})"); + std::thread::sleep(Duration::from_millis(500)); + } + Err(e) => { + eprintln!("FATAL: cannot connect serial socket {serial}: {e}"); + exit(1); + } + } + }; + eprintln!("rs485: connected to {serial}, slave address {address}, gap {gap:?}"); + + if let Err(e) = pump_serial(&stream, &bus.slave, gap) { + eprintln!("FATAL: serial pump: {e}"); + exit(1); + } + eprintln!("rs485: serial closed (VM gone), exiting"); +} diff --git a/qemu/run.sh b/qemu/run.sh index 0868884..f157e8c 100755 --- a/qemu/run.sh +++ b/qemu/run.sh @@ -25,6 +25,7 @@ set -euo pipefail QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=qemu/blkdevparts.conf disable=SC1091 . "$QEMU_DIR/blkdevparts.conf" OUT="${OUT:-$QEMU_DIR/out}" @@ -54,10 +55,10 @@ while [ $# -gt 0 ]; do esac done -[ -n "$KERNEL" ] && [ -f "$KERNEL" ] || { +if [ -z "$KERNEL" ] || [ ! -f "$KERNEL" ]; then echo "FATAL: --kernel required and must exist (got '${KERNEL:-}')" >&2 exit 1 -} +fi [ -f "$INITRD" ] || { echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh" >&2 exit 1 @@ -76,30 +77,30 @@ fi # 0xff4c0000, which does not exist on -M virt. APPEND="console=ttyAMA0 rdinit=/init" ARGS=( - -M virt -cpu cortex-a7 -smp 1 -m 256M + -M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M -kernel "$KERNEL" -initrd "$INITRD" -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443" - -device virtio-net-device,netdev=n0 + -device "virtio-net-device,netdev=n0" -no-reboot ) if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then APPEND="$APPEND blkdevparts=$WARDEN_BLKDEVPARTS warden.slot=$SLOT" ARGS+=( -drive "if=none,file=$DISK,format=raw,id=vd0" - -device virtio-blk-device,drive=vd0 ) + -device "virtio-blk-device,drive=vd0" ) fi [ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell" [ -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" - -device pci-serial,chardev=rs485 ) + -device "pci-serial,chardev=rs485" ) [ -n "$QMP" ] && ARGS+=( -qmp "unix:$QMP,server=on,wait=off" ) case "$DISPLAY_MODE" in off) ARGS+=( -nographic ) ;; - on) ARGS+=( -device virtio-gpu-device,xres=720,yres=720 + on) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720" -device virtio-tablet-device -serial mon:stdio ) ;; - headless) ARGS+=( -device virtio-gpu-device,xres=720,yres=720 + headless) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720" -device virtio-tablet-device -display none -serial mon:stdio ) ;; *) echo "FATAL: --display must be off|on|headless" >&2; exit 1 ;; esac diff --git a/qemu/tests/boot-smoke.sh b/qemu/tests/boot-smoke.sh index d661d2e..c8ab969 100755 --- a/qemu/tests/boot-smoke.sh +++ b/qemu/tests/boot-smoke.sh @@ -10,10 +10,10 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/ QDIR="$(cd "$HERE/.." && pwd)" # qemu/ ZIMAGE="${1:-}" -[ -n "$ZIMAGE" ] && [ -f "$ZIMAGE" ] || { +if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then echo "FATAL: usage: $0 [initramfs] — zImage missing or not a file: '${ZIMAGE:-}'" >&2 exit 1 -} +fi INITRD="${2:-$QDIR/out/initramfs.cpio.gz}" [ -f "$INITRD" ] || { echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh first" >&2 @@ -30,7 +30,7 @@ trap 'rm -f "$LOG"' EXIT # NOTE: never pass `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's # 0xff4c0000, which does not exist on -M virt. timeout 180 qemu-system-arm \ - -M virt -cpu cortex-a7 -smp 1 -m 256M \ + -M virt,highmem=off -cpu cortex-a7 -smp 1 -m 256M \ -kernel "$ZIMAGE" -initrd "$INITRD" \ -append "console=ttyAMA0 rdinit=/init" \ -nographic -no-reboot +# Env: FLARE_EDGE path to a flare-edge checkout (provides mock-flare-portal.py) +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 — 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 (mock-flare-portal.py not found under '${FLARE_EDGE:-}')" >&2 + exit 1 +fi +[ -x "$QDIR/payload/warden-flared" ] || { + echo "FATAL: no qemu/payload/warden-flared — build a static musl armv7 flared (see qemu/payload/README.md)" >&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 +} + +# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars. +WORK="$(mktemp -d /tmp/wqp.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. a real signed tier-1 .wfw offer (version above the image's 0.0.1), so the +# scenario exercises desired-state -> download -> signature/hash verify, not +# just an empty 204. Signed with the committed desk key; the payload flared +# is a FLARED_DEV_KEY=1 build that trusts it (desk-testing only). +head -c 8388608 /dev/urandom > "$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 on the host, our device pre-registered (no pairing needed — +# the same credential-seeding shortcut fw-e2e-test.sh uses), offering the .wfw. +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=$! +for _ in $(seq 1 50); do + curl -so /dev/null "http://127.0.0.1:$PORT/" && 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 +echo "== mock portal on :$PORT, device $DEVICE_ID" + +# 2. image seeded with the portal URL + credentials. +bash "$QDIR/mkinitramfs.sh" +# All four enrolment keys — flare::enrolment() returns None (and the report +# loop parks forever) unless flare.site is present too. +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" + +# 3. boot the VM headless (daemons run; console log to file). +bash "$QDIR/run.sh" --kernel "$ZIMAGE" \ + --ssh-port $((PORT + 1)) --http-port $((PORT + 2)) --api-port $((PORT + 3)) \ + > "$WORK/console.log" 2>&1 & +QEMU_PID=$! + +# 4. assert: rootfs up, and the portal saw — from OUR device id — an +# authenticated check-in, the firmware desired-state pull, and the signed +# .wfw asset download (i.e. flared accepted the offer and fetched it; the +# verify+stage+APPLYING that follow are a dry run without +# WARDEN_FW_ALLOW_APPLY, exactly like fw-e2e-test.sh). +deadline=$((SECONDS + 420)) +ok_boot=0 ok_report=0 ok_fw=0 ok_asset=0 +while [ $SECONDS -lt $deadline ]; do + [ $ok_boot -eq 0 ] && grep -aq 'WARDEN-QEMU-ROOTFS-OK' "$WORK/console.log" && { + ok_boot=1; echo "== VM userspace up"; } + grep -aq "POST /api/v1/devices/$DEVICE_ID/report -> 200" "$WORK/mock.log" && ok_report=1 + grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware -> 200" "$WORK/mock.log" && ok_fw=1 + grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" && ok_asset=1 + [ $ok_report -eq 1 ] && [ $ok_fw -eq 1 ] && [ $ok_asset -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 2 +done + +echo "== portal log (our device's requests):" +grep -a "$DEVICE_ID" "$WORK/mock.log" | tail -5 || true + +fail=0 +[ $ok_boot -eq 1 ] || { echo "FAIL: VM never reached WARDEN-QEMU-ROOTFS-OK"; fail=1; } +[ $ok_report -eq 1 ] || { echo "FAIL: no authenticated check-in (POST report 200) seen"; fail=1; } +[ $ok_fw -eq 1 ] || { echo "FAIL: no firmware desired-state pull (GET firmware 200) seen"; fail=1; } +[ $ok_asset -eq 1 ] || { echo "FAIL: signed .wfw asset was never downloaded"; fail=1; } +if [ $fail -eq 0 ]; then echo "PORTAL-SCENARIO-PASS"; else echo "PORTAL-SCENARIO-FAIL"; exit 1; fi