From b875aa9946b15d4a360b4c24985a827fa557c529 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Sat, 3 Oct 2026 12:36:25 +0200 Subject: [PATCH 1/5] harness: Seed a CS image from a file The QEMU runner filled each FMC backing image with one byte, so a test could hand the device erased flash and nothing else. The integration test needs the device to already hold a firmware image the update agent can offer. cs0_contents and cs1_contents name a file to copy in at offset 0; the remainder stays 0xFF. Without them the fill byte behaves as before, so every existing test is unchanged. A contents file longer than flash_size is an error rather than a truncation. A half-written image still parses far enough to fail verification, which would make a verify-failure scenario pass for the wrong reason. Assisted-by: Claude --- target/ast10x0/defs.bzl | 31 ++++++++++-- target/ast10x0/harness/BUILD.bazel | 16 +++++++ target/ast10x0/harness/qemu_runner.py | 47 ++++++++++++++---- target/ast10x0/harness/qemu_runner_test.py | 55 ++++++++++++++++++++++ 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 target/ast10x0/harness/qemu_runner_test.py diff --git a/target/ast10x0/defs.bzl b/target/ast10x0/defs.bzl index 6a2690210..dc2078207 100644 --- a/target/ast10x0/defs.bzl +++ b/target/ast10x0/defs.bzl @@ -9,7 +9,7 @@ TARGET_COMPATIBLE_WITH = select({ "//conditions:default": ["@platforms//:incompatible"], }) -def _system_image_test_impl(ctx): +def _system_image_test_impl(ctx, extra_runfiles = []): image_info = ctx.attr.image[SystemImageInfo] executable_symlink = ctx.actions.declare_file(ctx.label.name) ctx.actions.symlink(output = executable_symlink, target_file = image_info.elf) @@ -31,15 +31,15 @@ def _system_image_test_impl(ctx): runfiles.merge(ctx.attr.slave_image[DefaultInfo].default_runfiles), ) + if extra_runfiles: + runfiles = runfiles.merge(ctx.runfiles(files = extra_runfiles)) + return [DefaultInfo( executable = executable_symlink, runfiles = runfiles, )] def _flash_system_image_test_impl(ctx): - default_info = _system_image_test_impl(ctx)[0] - providers = [default_info] - # fmc_model describes the QEMU FMC device uniformly (JEDEC ID + SFDP # geometry), shared by both chip selects. The qemu_runner seeds fresh # images at $TEST_TMPDIR/ and attaches each present CS image as @@ -54,6 +54,19 @@ def _flash_system_image_test_impl(ctx): if ctx.attr.cs1_image: env["AST10X0_CS1_IMAGE"] = ctx.attr.cs1_image env["AST10X0_CS1_FILL"] = str(ctx.attr.cs1_fill) + + # A contents file is copied into the image instead of filling it, so the + # device already holds something when the guest boots. The runner opens + # the path relative to the runfiles tree the test runs in. + contents = [] + if ctx.file.cs0_contents: + contents.append(ctx.file.cs0_contents) + env["AST10X0_CS0_CONTENTS"] = ctx.file.cs0_contents.short_path + if ctx.file.cs1_contents: + contents.append(ctx.file.cs1_contents) + env["AST10X0_CS1_CONTENTS"] = ctx.file.cs1_contents.short_path + + providers = [_system_image_test_impl(ctx, extra_runfiles = contents)[0]] if ctx.attr.cs0_image or ctx.attr.cs1_image: providers.append(RunEnvironmentInfo(environment = env)) return providers @@ -83,6 +96,11 @@ flash_system_image_test = rule( implementation = _flash_system_image_test_impl, test = True, attrs = { + "cs0_contents": attr.label( + doc = "File copied into cs0_image at offset 0 instead of filling " + + "it. The rest stays erased. Longer than flash_size is an error.", + allow_single_file = True, + ), "cs0_fill": attr.int( doc = "Byte value the qemu_runner seeds cs0_image with (default 0xFF, erased).", default = 0xFF, @@ -92,6 +110,11 @@ flash_system_image_test = rule( "attached as FMC CS0 flash (if=mtd, index=0).", default = "", ), + "cs1_contents": attr.label( + doc = "File copied into cs1_image at offset 0 instead of filling " + + "it. The rest stays erased. Longer than flash_size is an error.", + allow_single_file = True, + ), "cs1_fill": attr.int( doc = "Byte value the qemu_runner seeds cs1_image with (default 0xFF, erased).", default = 0xFF, diff --git a/target/ast10x0/harness/BUILD.bazel b/target/ast10x0/harness/BUILD.bazel index 419fab50e..aa53726f4 100644 --- a/target/ast10x0/harness/BUILD.bazel +++ b/target/ast10x0/harness/BUILD.bazel @@ -3,6 +3,7 @@ load("@rules_platform//platform_data:defs.bzl", "platform_data") load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//python:py_test.bzl", "py_test") py_binary( name = "qemu_runner_bin", @@ -25,6 +26,21 @@ platform_data( visibility = ["//visibility:public"], ) +py_test( + name = "qemu_runner_test", + srcs = [ + "qemu_runner.py", + "qemu_runner_test.py", + ], + imports = ["."], + main = "qemu_runner_test.py", + deps = [ + "@@pigweed++cipd+pigweed.qemu//:qemu-system-arm-runfiles", + "@pigweed//pw_tokenizer/py:detokenize", + "@rules_python//python/runfiles", + ], +) + py_binary( name = "test_runner_bin", srcs = [ diff --git a/target/ast10x0/harness/qemu_runner.py b/target/ast10x0/harness/qemu_runner.py index b6e2e3a28..5fbc5a286 100644 --- a/target/ast10x0/harness/qemu_runner.py +++ b/target/ast10x0/harness/qemu_runner.py @@ -131,14 +131,37 @@ def _sentinel_watcher( print(f"Exception watching sentinel: {e}", file=sys.stderr) -def _seed_flash_image(path: str, size: int, fill: int = 0xFF) -> None: - """Create/overwrite `path` with `size` bytes of `fill` (0xFF = erased).""" +def _seed_flash_image( + path: str, size: int, fill: int = 0xFF, contents: str = "" +) -> None: + """Create/overwrite `path` with `size` bytes. + + Without `contents` the image is `size` bytes of `fill` (0xFF = erased). + With it, the file is copied in at offset 0 and the remainder is left + erased, so a test can hand the device a real image to read. + + A `contents` file longer than `size` is an error rather than a truncation: + a half-written image still parses far enough to fail verification, which + would make a verify-failure scenario pass for the wrong reason. + """ + if not contents: + with open(path, "wb") as f: + f.write(bytes([fill & 0xFF]) * size) + return + + data = Path(contents).read_bytes() + if len(data) > size: + raise ValueError( + f"{contents} is {len(data)} bytes, larger than the {size}-byte " + f"flash it seeds" + ) with open(path, "wb") as f: - f.write(bytes([fill & 0xFF]) * size) + f.write(data) + f.write(b"\xff" * (size - len(data))) def _resolve_flash_drives(args): - """Return a list of (index, path, size, fill) FMC backing images. + """Return a list of (index, path, size, fill, contents) FMC backing images. index 0 -> FMC CS0, index 1 -> FMC CS1. Each image is re-seeded on every run so tests start from a known device state. An explicit --flash-image @@ -146,20 +169,26 @@ def _resolve_flash_drives(args): AST10X0_CS0_IMAGE / AST10X0_CS1_IMAGE (basenames) plus AST10X0_FLASH_SIZE and per-CS AST10X0_CS0_FILL / AST10X0_CS1_FILL, resolved against $TEST_TMPDIR so each run gets private, freshly-seeded images. + + AST10X0_CS0_CONTENTS / AST10X0_CS1_CONTENTS name a file to copy in at + offset 0 instead of filling, for a test that needs the device to already + hold an image. The path is a runfile, resolved by the test rule. """ base = os.environ.get("TEST_TMPDIR", tempfile.gettempdir()) size = int(os.environ.get("AST10X0_FLASH_SIZE", str(args.flash_size))) drives = [] if args.flash_image: - drives.append((1, args.flash_image, size, 0xFF)) + drives.append((1, args.flash_image, size, 0xFF, "")) cs0 = os.environ.get("AST10X0_CS0_IMAGE") if cs0: fill = int(os.environ.get("AST10X0_CS0_FILL", "255")) - drives.append((0, os.path.join(base, cs0), size, fill)) + contents = os.environ.get("AST10X0_CS0_CONTENTS", "") + drives.append((0, os.path.join(base, cs0), size, fill, contents)) cs1 = os.environ.get("AST10X0_CS1_IMAGE") if cs1: fill = int(os.environ.get("AST10X0_CS1_FILL", "255")) - drives.append((1, os.path.join(base, cs1), size, fill)) + contents = os.environ.get("AST10X0_CS1_CONTENTS", "") + drives.append((1, os.path.join(base, cs1), size, fill, contents)) return drives @@ -191,8 +220,8 @@ def _main(args) -> None: args.image, ] - for index, path, size, fill in drives: - _seed_flash_image(path, size, fill) + for index, path, size, fill, contents in drives: + _seed_flash_image(path, size, fill, contents) qemu_args += [ "-drive", f"file={path},format=raw,if=mtd,index={index}", diff --git a/target/ast10x0/harness/qemu_runner_test.py b/target/ast10x0/harness/qemu_runner_test.py new file mode 100644 index 000000000..d2fe10098 --- /dev/null +++ b/target/ast10x0/harness/qemu_runner_test.py @@ -0,0 +1,55 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the flash seeding in qemu_runner.""" + +import tempfile +import unittest + +from pathlib import Path + +from qemu_runner import _seed_flash_image + + +class SeedFlashImageTest(unittest.TestCase): + """A seeded image is always exactly flash_size bytes.""" + + def setUp(self) -> None: + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.image = str(Path(self._dir.name) / "cs1.img") + + def _contents_file(self, data: bytes) -> str: + path = Path(self._dir.name) / "contents.bin" + path.write_bytes(data) + return str(path) + + def test_without_contents_the_image_is_all_fill(self) -> None: + _seed_flash_image(self.image, 32, fill=0xFF) + self.assertEqual(Path(self.image).read_bytes(), b"\xff" * 32) + + def test_fill_is_honoured(self) -> None: + _seed_flash_image(self.image, 8, fill=0x00) + self.assertEqual(Path(self.image).read_bytes(), b"\x00" * 8) + + def test_contents_land_at_offset_zero_and_the_rest_is_erased(self) -> None: + _seed_flash_image(self.image, 16, contents=self._contents_file(b"openprot")) + self.assertEqual(Path(self.image).read_bytes(), b"openprot" + b"\xff" * 8) + + def test_contents_override_fill(self) -> None: + """Fill describes the erased remainder, not the seeded bytes.""" + _seed_flash_image(self.image, 4, fill=0x00, contents=self._contents_file(b"ab")) + self.assertEqual(Path(self.image).read_bytes(), b"ab\xff\xff") + + def test_contents_may_fill_the_whole_image(self) -> None: + _seed_flash_image(self.image, 4, contents=self._contents_file(b"abcd")) + self.assertEqual(Path(self.image).read_bytes(), b"abcd") + + def test_contents_larger_than_the_flash_is_an_error(self) -> None: + """Truncating would hand the guest an image that fails for the + wrong reason.""" + with self.assertRaises(ValueError): + _seed_flash_image(self.image, 4, contents=self._contents_file(b"abcde")) + + +if __name__ == "__main__": + unittest.main() From 2035289090b77a64348d3e4c58a42026018ce4b0 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Sat, 3 Oct 2026 12:46:12 +0200 Subject: [PATCH 2/5] tools: Add the flash image builder A QEMU test that offers a firmware image needs that image on flash before the guest boots. The harness can now seed a chip select from a file; this makes the file. The layout arrives as arguments but is built with the same Region, Slot and ImageLayout constructors the board tables use, so a layout this tool accepts is one the orchestrator would accept: unique slot ids, no overlap, no zero-length region, nothing past the end of the offset space. Re-deriving those rules here would let a test image drift from what the firmware believes about the same flash. Everything no image covers stays 0xFF, and a payload too large for its slot is an error rather than a truncation. The flash_image rule wraps the tool so a BUILD file can hand the result straight to cs0_contents or cs1_contents. Assisted-by: Claude --- tools/flash_image/BUILD.bazel | 40 ++++ tools/flash_image/defs.bzl | 67 +++++++ tools/flash_image/example_image_test.sh | 36 ++++ tools/flash_image/main.rs | 199 ++++++++++++++++++ tools/flash_image/testdata/slot_a.bin | 1 + tools/flash_image/testdata/slot_b.bin | 1 + tools/flash_image/tests.rs | 255 ++++++++++++++++++++++++ 7 files changed, 599 insertions(+) create mode 100644 tools/flash_image/BUILD.bazel create mode 100644 tools/flash_image/defs.bzl create mode 100755 tools/flash_image/example_image_test.sh create mode 100644 tools/flash_image/main.rs create mode 100644 tools/flash_image/testdata/slot_a.bin create mode 100644 tools/flash_image/testdata/slot_b.bin create mode 100644 tools/flash_image/tests.rs diff --git a/tools/flash_image/BUILD.bazel b/tools/flash_image/BUILD.bazel new file mode 100644 index 000000000..d1c933ad7 --- /dev/null +++ b/tools/flash_image/BUILD.bazel @@ -0,0 +1,40 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load(":defs.bzl", "flash_image") + +# Host build tool: makes the flash image a QEMU test hands the device. +rust_binary( + name = "flash_image", + srcs = [ + "main.rs", + "tests.rs", + ], + edition = "2024", + visibility = ["//visibility:public"], + deps = ["//services/orchestrator/config:orchestrator_config"], +) + +rust_test( + name = "flash_image_test", + crate = ":flash_image", +) + +# Proves the rule wiring end to end: the builder runs under Bazel, both +# payloads land at their bases, and the result is usable as cs0_contents or +# cs1_contents on a flash_system_image_test. +flash_image( + name = "example_image", + flash_size = 64, + slots = { + "0:0x0:0x20": "testdata/slot_a.bin", + "1:0x20:0x20": "testdata/slot_b.bin", + }, +) + +sh_test( + name = "example_image_test", + srcs = ["example_image_test.sh"], + data = [":example_image"], +) diff --git a/tools/flash_image/defs.bzl b/tools/flash_image/defs.bzl new file mode 100644 index 000000000..be552711a --- /dev/null +++ b/tools/flash_image/defs.bzl @@ -0,0 +1,67 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +"""Builds a flash image from a slot layout, for QEMU tests to hand the device.""" + +def _flash_image_impl(ctx): + image = ctx.actions.declare_file(ctx.label.name + ".img") + + args = ctx.actions.args() + args.add("--flash-size", ctx.attr.flash_size) + args.add("--output", image) + + payloads = [] + for spec, target in ctx.attr.slots.items(): + files = target.files.to_list() + if len(files) != 1: + fail("slot {}: {} is not a single file".format(spec, target.label)) + payloads.append(files[0]) + args.add("--slot", "{}={}".format(spec, files[0].path)) + + if ctx.attr.golden: + files = ctx.attr.golden.files.to_list() + if len(files) != 1: + fail("golden: {} is not a single file".format(ctx.attr.golden.label)) + payloads.append(files[0]) + args.add("--golden", "{}={}".format(ctx.attr.golden_region, files[0].path)) + + ctx.actions.run( + mnemonic = "FlashImage", + executable = ctx.executable._builder, + arguments = [args], + inputs = payloads, + outputs = [image], + ) + + return [DefaultInfo(files = depset([image]))] + +flash_image = rule( + implementation = _flash_image_impl, + doc = "A flash image with each payload written at its slot's base, the " + + "rest left erased. The layout goes through the same constructors " + + "the board tables use, so a layout the orchestrator would refuse " + + "fails the build here.", + attrs = { + "flash_size": attr.int( + doc = "Size in bytes of the image, matching the test's flash_size.", + mandatory = True, + ), + "golden": attr.label( + doc = "Payload for the golden image, if the layout has one.", + allow_single_file = True, + ), + "golden_region": attr.string( + doc = "Where the golden image lives, as base:len.", + default = "", + ), + "slots": attr.string_keyed_label_dict( + doc = "Maps id:base:len to the file that fills that slot. Base " + + "and len take decimal or 0x hex.", + allow_files = True, + ), + "_builder": attr.label( + default = "//tools/flash_image:flash_image", + executable = True, + cfg = "exec", + ), + }, +) diff --git a/tools/flash_image/example_image_test.sh b/tools/flash_image/example_image_test.sh new file mode 100755 index 000000000..32884e2d6 --- /dev/null +++ b/tools/flash_image/example_image_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +# +# Checks the image the flash_image rule built: both payloads at their slot +# bases, erased everywhere else, and exactly flash_size bytes. This covers +# the Bazel wiring; the layout rules themselves are covered by +# //tools/flash_image:flash_image_test. + +set -euo pipefail + +image="${TEST_SRCDIR}/_main/tools/flash_image/example_image.img" + +# wc rather than stat: runfiles entries are symlinks, and GNU stat reports +# the link itself unless told otherwise. +actual_size=$(wc -c < "${image}") +if [ "${actual_size}" -ne 64 ]; then + echo "expected a 64-byte image, got ${actual_size}" >&2 + exit 1 +fi + +# od keeps this readable: slot A at 0x00, slot B at 0x20, 0xFF between. +expected=$( + cat <<'EOF' +0000000 S L O T - A - C O N T E N T 377 377 +0000020 377 377 377 377 377 377 377 377 377 377 377 377 377 377 377 377 +0000040 S L O T - B 377 377 377 377 377 377 377 377 377 377 +0000060 377 377 377 377 377 377 377 377 377 377 377 377 377 377 377 377 +0000100 +EOF +) + +if ! diff <(od -c "${image}") <(echo "${expected}"); then + echo "image contents do not match the declared layout" >&2 + exit 1 +fi diff --git a/tools/flash_image/main.rs b/tools/flash_image/main.rs new file mode 100644 index 000000000..6d0f17ff6 --- /dev/null +++ b/tools/flash_image/main.rs @@ -0,0 +1,199 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Writes a flash image for a QEMU test from a board's slot layout. +//! +//! The layout comes in as arguments but is built with the same +//! `Region`/`Slot`/`ImageLayout` constructors the board tables use, so a +//! layout this tool accepts is one the orchestrator would accept: slot ids +//! unique, no two regions overlapping, no zero-length region, nothing past +//! the end of the offset space. Re-deriving those rules here would let a +//! test image drift from what the firmware believes. +//! +//! Everything the layout does not name stays 0xFF, so the image looks like +//! erased flash with images written into it. +//! +//! ```text +//! flash-image --flash-size 0x4000000 \ +//! --slot 0:0x0:0x100000=slot_a.bin \ +//! --slot 1:0x100000:0x100000=slot_b.bin \ +//! --golden 0x200000:0x100000=golden.bin \ +//! --output cs1.img +//! ``` + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +use orchestrator_config::{Golden, ImageLayout, Region, Slot, SlotId}; + +/// Erased flash. Everything no image covers keeps this value. +const ERASED: u8 = 0xFF; + +/// One `--slot` or `--golden` argument: where the image goes and what +/// fills it. +struct Placement { + region: Region, + payload: PathBuf, +} + +/// `base:len=path`, with base and len in decimal or 0x hex. +fn parse_region(spec: &str) -> Result { + let (region, path) = spec + .split_once('=') + .ok_or_else(|| format!("{spec}: expected base:len=path"))?; + let (base, len) = region + .split_once(':') + .ok_or_else(|| format!("{spec}: expected base:len=path"))?; + Ok(Placement { + region: Region::new(parse_u32(base)?, parse_u32(len)?), + payload: PathBuf::from(path), + }) +} + +/// `id:base:len=path`, the slot id in front of a region spec. +fn parse_slot(spec: &str) -> Result<(SlotId, Placement), String> { + let (id, rest) = spec + .split_once(':') + .ok_or_else(|| format!("{spec}: expected id:base:len=path"))?; + let id: u8 = id + .parse() + .map_err(|_| format!("{id}: slot id must be 0 to 255"))?; + Ok((SlotId(id), parse_region(rest)?)) +} + +fn parse_u32(text: &str) -> Result { + let parsed = match text.strip_prefix("0x") { + Some(hex) => u32::from_str_radix(hex, 16), + None => text.parse(), + }; + parsed.map_err(|_| format!("{text}: not a 32-bit number")) +} + +struct Args { + flash_size: u32, + slots: Vec<(SlotId, Placement)>, + golden: Option, + output: PathBuf, +} + +fn parse_args(argv: impl Iterator) -> Result { + let mut flash_size = None; + let mut slots = Vec::new(); + let mut golden = None; + let mut output = None; + + let mut argv = argv.peekable(); + while let Some(flag) = argv.next() { + let mut value = || { + argv.next() + .ok_or_else(|| format!("{flag}: missing its value")) + }; + match flag.as_str() { + "--flash-size" => flash_size = Some(parse_u32(&value()?)?), + "--slot" => slots.push(parse_slot(&value()?)?), + "--golden" => golden = Some(parse_region(&value()?)?), + "--output" => output = Some(PathBuf::from(value()?)), + other => return Err(format!("{other}: unknown argument")), + } + } + + Ok(Args { + flash_size: flash_size.ok_or("--flash-size is required")?, + slots, + golden, + output: output.ok_or("--output is required")?, + }) +} + +/// Builds the image. The layout is constructed first so a bad one is +/// rejected before any file is read. +fn build(args: &Args) -> Result, String> { + // ImageLayout borrows the slots for 'static, matching board tables + // that declare them as consts. A build tool runs once and exits, so + // leaking the one list it makes costs nothing. + let declared: Vec = args + .slots + .iter() + .map(|(id, placement)| Slot::new(*id, placement.region)) + .collect(); + let layout = ImageLayout::new( + Box::leak(declared.into_boxed_slice()), + args.golden.as_ref().map(|g| Golden::new(g.region)), + ); + + // Slot ids are unique by now, so a payload per id is unambiguous. + let payloads: BTreeMap = args + .slots + .iter() + .map(|(id, placement)| (id.0, &placement.payload)) + .collect(); + + let mut image = vec![ERASED; args.flash_size as usize]; + let mut place = |region: Region, payload: &PathBuf, what: &str| { + let bytes = fs::read(payload).map_err(|e| format!("{}: {e}", payload.display()))?; + if bytes.len() > region.len() as usize { + return Err(format!( + "{}: {} bytes does not fit the {}-byte {what}", + payload.display(), + bytes.len(), + region.len() + )); + } + let base = region.base() as usize; + let end = base + bytes.len(); + if region.end() as usize > image.len() { + return Err(format!( + "{what} ends at {:#x}, past the {:#x}-byte flash", + region.end(), + image.len() + )); + } + image[base..end].copy_from_slice(&bytes); + Ok(()) + }; + + for slot in layout.slots() { + let payload = payloads[&slot.id().0]; + place(slot.region(), payload, "slot")?; + } + if let (Some(golden), Some(placement)) = (layout.golden(), args.golden.as_ref()) { + place(golden.region(), &placement.payload, "golden image")?; + } + + Ok(image) +} + +fn main() -> ExitCode { + // skip(1) drops argv[0], which is the part semgrep's rule is about: + // the caller chooses it and it need not name anything real. Nothing + // here reads it. + // nosemgrep: rust.lang.security.args.args + let args = match parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(e) => { + eprintln!("flash-image: {e}"); + return ExitCode::FAILURE; + } + }; + + let image = match build(&args) { + Ok(image) => image, + Err(e) => { + eprintln!("flash-image: {e}"); + return ExitCode::FAILURE; + } + }; + + match fs::write(&args.output, &image) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("flash-image: {}: {e}", args.output.display()); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests; diff --git a/tools/flash_image/testdata/slot_a.bin b/tools/flash_image/testdata/slot_a.bin new file mode 100644 index 000000000..1840751b6 --- /dev/null +++ b/tools/flash_image/testdata/slot_a.bin @@ -0,0 +1 @@ +SLOT-A-CONTENT \ No newline at end of file diff --git a/tools/flash_image/testdata/slot_b.bin b/tools/flash_image/testdata/slot_b.bin new file mode 100644 index 000000000..1e02e3704 --- /dev/null +++ b/tools/flash_image/testdata/slot_b.bin @@ -0,0 +1 @@ +SLOT-B \ No newline at end of file diff --git a/tools/flash_image/tests.rs b/tools/flash_image/tests.rs new file mode 100644 index 000000000..29304a286 --- /dev/null +++ b/tools/flash_image/tests.rs @@ -0,0 +1,255 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +/// Writes `data` to a uniquely named file under the test's temp dir and +/// returns its path. `name` only has to be unique within one test. +fn payload(name: &str, data: &[u8]) -> PathBuf { + let dir = std::env::var("TEST_TMPDIR").unwrap_or_else(|_| "/tmp".into()); + let path = PathBuf::from(dir).join(format!("flash-image-{name}.bin")); + fs::write(&path, data).unwrap(); + path +} + +fn args(flash_size: u32, slots: Vec<(SlotId, Placement)>) -> Args { + Args { + flash_size, + slots, + golden: None, + output: PathBuf::from("unused"), + } +} + +#[test] +fn parse_region_takes_decimal_and_hex() { + let p = parse_region("0x10:32=some/path").unwrap(); + assert_eq!(p.region.base(), 0x10); + assert_eq!(p.region.len(), 32); + assert_eq!(p.payload, PathBuf::from("some/path")); +} + +#[test] +fn parse_slot_reads_the_id_first() { + let (id, p) = parse_slot("3:0:16=x").unwrap(); + assert_eq!(id.0, 3); + assert_eq!(p.region.len(), 16); +} + +#[test] +fn a_spec_without_a_payload_is_refused() { + assert!(parse_region("0:16").is_err()); +} + +#[test] +fn a_spec_without_a_length_is_refused() { + assert!(parse_region("0=x").is_err()); +} + +#[test] +fn an_unknown_argument_is_refused() { + let argv = ["--rubbish".to_string(), "1".to_string()]; + assert!(parse_args(argv.into_iter()).is_err()); +} + +#[test] +fn a_flag_without_its_value_is_refused() { + let argv = ["--flash-size".to_string()]; + assert!(parse_args(argv.into_iter()).is_err()); +} + +#[test] +fn payloads_land_at_their_slot_base_and_the_rest_stays_erased() { + let a = payload("land-a", b"AAAA"); + let b = payload("land-b", b"BB"); + let image = build(&args( + 32, + vec![ + ( + SlotId(0), + Placement { + region: Region::new(0, 16), + payload: a, + }, + ), + ( + SlotId(1), + Placement { + region: Region::new(16, 16), + payload: b, + }, + ), + ], + )) + .unwrap(); + + assert_eq!(&image[0..4], b"AAAA"); + assert_eq!(&image[4..16], &[ERASED; 12]); + assert_eq!(&image[16..18], b"BB"); + assert_eq!(&image[18..32], &[ERASED; 14]); +} + +#[test] +fn declaration_order_does_not_have_to_match_address_order() { + let high = payload("order-high", b"H"); + let low = payload("order-low", b"L"); + let image = build(&args( + 32, + vec![ + ( + SlotId(7), + Placement { + region: Region::new(16, 16), + payload: high, + }, + ), + ( + SlotId(2), + Placement { + region: Region::new(0, 16), + payload: low, + }, + ), + ], + )) + .unwrap(); + + assert_eq!(image[0], b'L'); + assert_eq!(image[16], b'H'); +} + +#[test] +fn a_payload_larger_than_its_slot_is_refused() { + let big = payload("too-big", b"AAAAAAAA"); + let err = build(&args( + 32, + vec![( + SlotId(0), + Placement { + region: Region::new(0, 4), + payload: big, + }, + )], + )) + .unwrap_err(); + assert!(err.contains("does not fit"), "{err}"); +} + +#[test] +fn a_slot_past_the_end_of_the_flash_is_refused() { + let p = payload("past-end", b"A"); + let err = build(&args( + 16, + vec![( + SlotId(0), + Placement { + region: Region::new(8, 16), + payload: p, + }, + )], + )) + .unwrap_err(); + assert!(err.contains("past the"), "{err}"); +} + +#[test] +fn a_missing_payload_is_refused() { + let err = build(&args( + 16, + vec![( + SlotId(0), + Placement { + region: Region::new(0, 8), + payload: PathBuf::from("no/such/file"), + }, + )], + )) + .unwrap_err(); + assert!(err.contains("no/such/file"), "{err}"); +} + +#[test] +fn the_golden_image_is_written_too() { + let slot = payload("golden-slot", b"S"); + let golden = payload("golden-image", b"G"); + let image = build(&Args { + flash_size: 32, + slots: vec![( + SlotId(0), + Placement { + region: Region::new(0, 16), + payload: slot, + }, + )], + golden: Some(Placement { + region: Region::new(16, 16), + payload: golden, + }), + output: PathBuf::from("unused"), + }) + .unwrap(); + + assert_eq!(image[0], b'S'); + assert_eq!(image[16], b'G'); +} + +/// `ImageLayout::new` is what rejects these, not any check in this tool. +/// The point of the panic is that a test image cannot describe a layout +/// the orchestrator would refuse to boot. +#[test] +#[should_panic(expected = "slots must not overlap")] +fn overlapping_slots_are_rejected_by_the_layout() { + let a = payload("overlap-a", b"A"); + let b = payload("overlap-b", b"B"); + let _ = build(&args( + 64, + vec![ + ( + SlotId(0), + Placement { + region: Region::new(0, 32), + payload: a, + }, + ), + ( + SlotId(1), + Placement { + region: Region::new(16, 32), + payload: b, + }, + ), + ], + )); +} + +#[test] +#[should_panic(expected = "slot ids must be unique")] +fn duplicate_slot_ids_are_rejected_by_the_layout() { + let a = payload("dup-a", b"A"); + let b = payload("dup-b", b"B"); + let _ = build(&args( + 64, + vec![ + ( + SlotId(0), + Placement { + region: Region::new(0, 16), + payload: a, + }, + ), + ( + SlotId(0), + Placement { + region: Region::new(16, 16), + payload: b, + }, + ), + ], + )); +} + +#[test] +#[should_panic(expected = "region length must not be zero")] +fn a_zero_length_region_is_rejected_by_the_layout() { + let _ = parse_region("0:0=x"); +} From b1237d1e1aeb1386dcec4edae2df73676177a77d Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Sat, 3 Oct 2026 14:08:11 +0200 Subject: [PATCH 3/5] services/test: Add the mock BMC boot model The QEMU integration test needs something on the other end of the RoT's reset and ready lines. This is that device: held in reset the ready line stays inactive, and once reset is released it goes active after a delay, or never. Generic over InputPin and OutputPin, so the lines can be GPIO on a board or whatever the wiring supplies in a test image. Time arrives as an argument to poll and nothing waits, so the same model runs from a kernel event loop or from a test that moves the clock by hand. Asserting reset drops the ready line and restarts the delay. The RoT has to see evidence disappear when it resets a device, or a stale ready line would answer for the new boot. Boots and Hangs are the only behaviours. A late boot is Boots with a delay past the window the RoT waits, which makes lateness a property of the deadline rather than of the device. Assisted-by: Claude --- services/test/mock-bmc/BUILD.bazel | 25 +++ services/test/mock-bmc/src/lib.rs | 192 +++++++++++++++++++++ services/test/mock-bmc/src/tests.rs | 248 ++++++++++++++++++++++++++++ 3 files changed, 465 insertions(+) create mode 100644 services/test/mock-bmc/BUILD.bazel create mode 100644 services/test/mock-bmc/src/lib.rs create mode 100644 services/test/mock-bmc/src/tests.rs diff --git a/services/test/mock-bmc/BUILD.bazel b/services/test/mock-bmc/BUILD.bazel new file mode 100644 index 000000000..7a68a59b7 --- /dev/null +++ b/services/test/mock-bmc/BUILD.bazel @@ -0,0 +1,25 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "mock_bmc", + srcs = [ + "src/lib.rs", + "src/tests.rs", + ], + crate_name = "openprot_mock_bmc", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//hal/blocking", + "@rust_crates//:embedded-hal", + ], +) + +# Host tests: the model is pin-generic, so it runs without a kernel. +rust_test( + name = "mock_bmc_test", + crate = ":mock_bmc", +) diff --git a/services/test/mock-bmc/src/lib.rs b/services/test/mock-bmc/src/lib.rs new file mode 100644 index 000000000..dfe8efcf9 --- /dev/null +++ b/services/test/mock-bmc/src/lib.rs @@ -0,0 +1,192 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! A BMC that boots, or does not, on demand. +//! +//! The RoT drives a reset line and watches a ready line. This models what +//! sits on the other end of those two wires: hold the ready line low while +//! reset is asserted, and once reset is released, raise it after a delay, +//! or never. That is the whole vocabulary a boot-sequence test needs from +//! a managed device. +//! +//! Nothing here knows what the lines are made of. On hardware they are +//! GPIO; in a QEMU test both ends live in one image and the pins are +//! whatever the board wiring supplies. The model is generic over +//! `InputPin` and `OutputPin` so neither case is special. +//! +//! Time arrives as an argument. [`MockBmc::poll`] is given the current +//! millisecond count and never waits, so a caller can drive it from a +//! kernel event loop, or from a test that moves the clock by hand. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +use openprot_hal_blocking::gpio_port::ActivePolarity; +use openprot_hal_blocking::{InputPin, OutputPin}; + +/// What the mock BMC does once reset is released. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootBehaviour { + /// Raises the ready line `after_millis` after release. A boot that is + /// late rather than healthy is the same variant with a delay past the + /// window the RoT is willing to wait, so there is no third variant for + /// it: late is a property of the deadline, not of the device. + Boots { + /// Milliseconds from release to the ready line going active. + after_millis: u64, + }, + /// Never raises the ready line. The RoT finds out by its deadline + /// expiring, which is the only way it ever finds out. + Hangs, +} + +/// Where the modelled device is in its boot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + /// Reset is asserted. The ready line is inactive. + Held, + /// Reset is released and the boot delay has not elapsed. + Booting { + /// When the delay started, in the caller's millisecond count. + released_at_millis: u64, + }, + /// The ready line is active. + Ready, + /// Reset is released and this device will never report ready. + Hung, +} + +/// Why a poll could not be completed. Carries the pin error so a caller +/// sees which line failed, not just that one did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// The reset line could not be read. + Reset(R), + /// The ready line could not be driven. + Ready(Y), +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Reset(e) => write!(f, "reading the reset line: {e}"), + Self::Ready(e) => write!(f, "driving the ready line: {e}"), + } + } +} + +impl core::error::Error for Error +where + R: core::fmt::Debug + core::fmt::Display, + Y: core::fmt::Debug + core::fmt::Display, +{ +} + +/// The device on the other end of a reset line and a ready line. +pub struct MockBmc { + reset: Reset, + reset_polarity: ActivePolarity, + ready: Ready, + ready_polarity: ActivePolarity, + behaviour: BootBehaviour, + phase: Phase, +} + +impl MockBmc { + /// Wires the model to its two lines. + /// + /// Starts in [`Phase::Held`] without touching either line: the first + /// [`poll`](Self::poll) reads reset and drives ready to match, so a + /// caller that constructs this before the RoT has settled its outputs + /// does not latch a stale phase. + pub fn new( + reset: Reset, + reset_polarity: ActivePolarity, + ready: Ready, + ready_polarity: ActivePolarity, + behaviour: BootBehaviour, + ) -> Self { + Self { + reset, + reset_polarity, + ready, + ready_polarity, + behaviour, + phase: Phase::Held, + } + } + + /// Where the device is now. Only moves on [`poll`](Self::poll). + pub fn phase(&self) -> Phase { + self.phase + } + + /// Advances the model to `now_millis` and drives the ready line to + /// match. Never waits. + /// + /// Asserting reset at any point returns the device to [`Phase::Held`] + /// and drops the ready line, so a RoT that resets a device mid-boot + /// sees the evidence go away, exactly as it would on hardware. + /// + /// # Errors + /// + /// Returns [`Error::Reset`] if the reset line cannot be read and + /// [`Error::Ready`] if the ready line cannot be driven. + pub fn poll(&mut self, now_millis: u64) -> Result> { + if self.reset_asserted()? { + if self.phase != Phase::Held { + self.drive_ready(false)?; + self.phase = Phase::Held; + } + return Ok(self.phase); + } + + self.phase = match (self.phase, self.behaviour) { + // Released. Start the delay, or settle into never reporting. + (Phase::Held, BootBehaviour::Boots { .. }) => Phase::Booting { + released_at_millis: now_millis, + }, + (Phase::Held, BootBehaviour::Hangs) => Phase::Hung, + + // The delay is measured from release, so a caller that polls + // late still reports ready rather than restarting the count. + (Phase::Booting { released_at_millis }, BootBehaviour::Boots { after_millis }) => { + if now_millis.saturating_sub(released_at_millis) >= after_millis { + self.drive_ready(true)?; + Phase::Ready + } else { + Phase::Booting { released_at_millis } + } + } + + // Behaviour does not change under the model's feet, so the + // remaining pairs are either terminal or unreachable. + (phase, _) => phase, + }; + + Ok(self.phase) + } + + fn reset_asserted(&mut self) -> Result> { + let high = self.reset.is_high().map_err(Error::Reset)?; + Ok(match self.reset_polarity { + ActivePolarity::ActiveHigh => high, + ActivePolarity::ActiveLow => !high, + }) + } + + fn drive_ready(&mut self, active: bool) -> Result<(), Error> { + let high = match self.ready_polarity { + ActivePolarity::ActiveHigh => active, + ActivePolarity::ActiveLow => !active, + }; + if high { + self.ready.set_high().map_err(Error::Ready) + } else { + self.ready.set_low().map_err(Error::Ready) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/services/test/mock-bmc/src/tests.rs b/services/test/mock-bmc/src/tests.rs new file mode 100644 index 000000000..f2b074316 --- /dev/null +++ b/services/test/mock-bmc/src/tests.rs @@ -0,0 +1,248 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +use core::cell::Cell; +use std::rc::Rc; + +/// A pin pair the test drives and reads directly. Shared through `Rc` so a +/// test can hold the reset line while the model holds its other end. +#[derive(Clone, Default)] +struct Line(Rc>); + +impl Line { + fn high() -> Self { + Self(Rc::new(Cell::new(true))) + } + + fn low() -> Self { + Self(Rc::new(Cell::new(false))) + } + + fn set(&self, high: bool) { + self.0.set(high); + } + + fn is_set_high(&self) -> bool { + self.0.get() + } +} + +impl embedded_hal::digital::ErrorType for Line { + type Error = core::convert::Infallible; +} + +impl InputPin for Line { + fn is_high(&mut self) -> Result { + Ok(self.0.get()) + } + + fn is_low(&mut self) -> Result { + Ok(!self.0.get()) + } +} + +impl OutputPin for Line { + fn set_high(&mut self) -> Result<(), Self::Error> { + self.0.set(true); + Ok(()) + } + + fn set_low(&mut self) -> Result<(), Self::Error> { + self.0.set(false); + Ok(()) + } +} + +/// Reset active low (the RoT_BMC_RESET_L convention), ready active high. +/// Starts asserted: reset low means held. +fn wire(behaviour: BootBehaviour) -> (Line, Line, MockBmc) { + let reset = Line::low(); + let ready = Line::low(); + let bmc = MockBmc::new( + reset.clone(), + ActivePolarity::ActiveLow, + ready.clone(), + ActivePolarity::ActiveHigh, + behaviour, + ); + (reset, ready, bmc) +} + +/// Releases reset: drives the active-low line high. +fn release(reset: &Line) { + reset.set(true); +} + +/// Asserts reset: drives the active-low line low. +fn assert_reset(reset: &Line) { + reset.set(false); +} + +#[test] +fn held_in_reset_the_ready_line_stays_low() { + let (_reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 10 }); + + assert_eq!(bmc.poll(0).unwrap(), Phase::Held); + assert_eq!(bmc.poll(1_000).unwrap(), Phase::Held); + assert!(!ready.is_set_high()); +} + +#[test] +fn ready_goes_active_once_the_delay_has_passed() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 10 }); + + bmc.poll(0).unwrap(); + release(&reset); + + assert_eq!( + bmc.poll(100).unwrap(), + Phase::Booting { + released_at_millis: 100 + } + ); + assert!(!ready.is_set_high(), "ready before the delay elapsed"); + + assert_eq!( + bmc.poll(109).unwrap(), + Phase::Booting { + released_at_millis: 100 + } + ); + assert!(!ready.is_set_high(), "ready one millisecond early"); + + assert_eq!(bmc.poll(110).unwrap(), Phase::Ready); + assert!(ready.is_set_high()); +} + +#[test] +fn the_delay_runs_from_release_not_from_the_poll_that_notices() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 10 }); + + bmc.poll(0).unwrap(); + release(&reset); + bmc.poll(100).unwrap(); + + // A caller that goes away and comes back late still gets ready on the + // first poll past the deadline, not ten more milliseconds later. + assert_eq!(bmc.poll(5_000).unwrap(), Phase::Ready); + assert!(ready.is_set_high()); +} + +#[test] +fn a_zero_delay_reports_ready_on_the_first_poll_after_release() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 0 }); + + bmc.poll(0).unwrap(); + release(&reset); + + // Release is seen first, then the delay is judged, so this takes two + // polls. An event loop polls on every wake, so that costs nothing. + assert_eq!( + bmc.poll(7).unwrap(), + Phase::Booting { + released_at_millis: 7 + } + ); + assert_eq!(bmc.poll(7).unwrap(), Phase::Ready); + assert!(ready.is_set_high()); +} + +#[test] +fn a_hanging_device_never_reports_ready() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Hangs); + + bmc.poll(0).unwrap(); + release(&reset); + + assert_eq!(bmc.poll(1).unwrap(), Phase::Hung); + assert_eq!(bmc.poll(u64::MAX).unwrap(), Phase::Hung); + assert!(!ready.is_set_high()); +} + +#[test] +fn asserting_reset_drops_the_evidence() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 10 }); + + bmc.poll(0).unwrap(); + release(&reset); + bmc.poll(100).unwrap(); + bmc.poll(110).unwrap(); + assert!(ready.is_set_high()); + + assert_reset(&reset); + assert_eq!(bmc.poll(120).unwrap(), Phase::Held); + assert!(!ready.is_set_high(), "ready survived a reset"); +} + +#[test] +fn a_reset_mid_boot_restarts_the_delay() { + let (reset, ready, mut bmc) = wire(BootBehaviour::Boots { after_millis: 10 }); + + bmc.poll(0).unwrap(); + release(&reset); + bmc.poll(100).unwrap(); + + assert_reset(&reset); + assert_eq!(bmc.poll(105).unwrap(), Phase::Held); + + release(&reset); + assert_eq!( + bmc.poll(200).unwrap(), + Phase::Booting { + released_at_millis: 200 + } + ); + assert!(!ready.is_set_high(), "the old delay carried over"); + assert_eq!(bmc.poll(210).unwrap(), Phase::Ready); +} + +#[test] +fn a_hung_device_is_reset_like_any_other() { + let (reset, _ready, mut bmc) = wire(BootBehaviour::Hangs); + + bmc.poll(0).unwrap(); + release(&reset); + assert_eq!(bmc.poll(1).unwrap(), Phase::Hung); + + assert_reset(&reset); + assert_eq!(bmc.poll(2).unwrap(), Phase::Held); +} + +#[test] +fn an_active_high_reset_reads_the_other_way_round() { + let reset = Line::high(); + let ready = Line::low(); + let mut bmc = MockBmc::new( + reset.clone(), + ActivePolarity::ActiveHigh, + ready.clone(), + ActivePolarity::ActiveHigh, + BootBehaviour::Boots { after_millis: 0 }, + ); + + assert_eq!(bmc.poll(0).unwrap(), Phase::Held); + reset.set(false); + bmc.poll(1).unwrap(); + assert_eq!(bmc.poll(1).unwrap(), Phase::Ready); +} + +#[test] +fn an_active_low_ready_line_is_driven_low_when_active() { + let reset = Line::low(); + let ready = Line::high(); + let mut bmc = MockBmc::new( + reset.clone(), + ActivePolarity::ActiveLow, + ready.clone(), + ActivePolarity::ActiveLow, + BootBehaviour::Boots { after_millis: 0 }, + ); + + bmc.poll(0).unwrap(); + release(&reset); + bmc.poll(1).unwrap(); + assert_eq!(bmc.poll(1).unwrap(), Phase::Ready); + assert!(!ready.is_set_high(), "an active-low ready line reads low"); +} From 73987318153ca60ddb20c894e8b76b7da78dbd69 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Sat, 3 Oct 2026 21:39:08 +0200 Subject: [PATCH 4/5] services/mctp: Add the loopback transport A QEMU test has no wire and no second chip, but the PLDM firmware device still has to reach an update agent. This is the transport for that: each server queues what its router fragments, and the caller feeds that queue to the other server's inbound. One server's outbox is the other's wire. Two servers, not one looping back to itself. A message addressed to a server's own EID has no route out and back, which the router reports as BadArgument before the sender is ever consulted. The queue belongs to the caller rather than to the sender, because the router takes the sender by value and offers no way back to it. Draining in the caller's loop also keeps the send path non-blocking. The fragment buffer holds the MTU plus the four-byte MCTP header. A buffer of exactly the MTU makes the router refuse the send rather than truncate it. The arrangement comes from services/mctp/echo/tests/echo_host.rs, which does the same thing with test-local helpers. This is the library version, so an app can use it. Assisted-by: Claude --- services/mctp/transport-loopback/BUILD.bazel | 28 +++ services/mctp/transport-loopback/src/lib.rs | 160 ++++++++++++++++++ services/mctp/transport-loopback/src/tests.rs | 137 +++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 services/mctp/transport-loopback/BUILD.bazel create mode 100644 services/mctp/transport-loopback/src/lib.rs create mode 100644 services/mctp/transport-loopback/src/tests.rs diff --git a/services/mctp/transport-loopback/BUILD.bazel b/services/mctp/transport-loopback/BUILD.bazel new file mode 100644 index 000000000..b630f4896 --- /dev/null +++ b/services/mctp/transport-loopback/BUILD.bazel @@ -0,0 +1,28 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "mctp_transport_loopback", + srcs = [ + "src/lib.rs", + "src/tests.rs", + ], + crate_name = "openprot_mctp_transport_loopback", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "@rust_crates//:heapless", + "@rust_crates//:mctp", + "@rust_crates//:mctp-lib", + ], +) + +# Host tests: the round trip goes through a real server, which is why the +# server is a test-only dependency rather than a dependency of the library. +rust_test( + name = "mctp_transport_loopback_test", + crate = ":mctp_transport_loopback", + deps = ["//services/mctp/server:mctp_server_lib"], +) diff --git a/services/mctp/transport-loopback/src/lib.rs b/services/mctp/transport-loopback/src/lib.rs new file mode 100644 index 000000000..43a90ca40 --- /dev/null +++ b/services/mctp/transport-loopback/src/lib.rs @@ -0,0 +1,160 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! An MCTP transport that goes nowhere: packets come straight back in. +//! +//! On a board the server's [`Sender`] hands fragments to a wire. In a QEMU +//! test there is no wire and no second chip, so both endpoints live in one +//! image. This sender queues what its router fragments, and the caller +//! feeds that queue to the *other* endpoint's `Server::inbound`. One +//! server's outbox is the other's wire. +//! +//! The queue is owned by the caller rather than by the sender, because the +//! router takes the sender by value and offers no way back to it. The +//! caller keeps [`LoopbackQueue`] and lends it to [`LoopbackSender`]: +//! +//! ```ignore +//! let out_a = LoopbackQueue::<8>::new(); +//! let out_b = LoopbackQueue::<8>::new(); +//! let mut a = Server::new(Eid(8), now, LoopbackSender::new(&out_a)); +//! let mut b = Server::new(Eid(42), now, LoopbackSender::new(&out_b)); +//! // a sends; what a queued is what b receives. +//! while let Some(pkt) = out_a.take() { +//! b.inbound(&pkt)?; +//! } +//! ``` +//! +//! Two servers, not one looping to itself: a message addressed to a +//! server's own EID has no route out and back. +//! +//! Draining in the caller's loop rather than re-entering the router keeps +//! the send path non-blocking and the borrow straight, since the router +//! owns the sender and offers no way back to it. +//! +//! What this does not model: a wire that drops, reorders, delays or +//! corrupts. Every packet arrives, in order, as soon as the caller drains. +//! A test that wants retry or timeout behaviour needs something else. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +use core::cell::RefCell; + +use heapless::Deque; +use mctp::{Error, Result, Tag}; +use mctp_lib::fragment::{Fragmenter, SendOutput}; +use mctp_lib::Sender; + +/// Largest payload this transport carries in one fragment. Nothing here is +/// limited by a bus, so the number only has to be agreed by both ends, +/// which it is by construction. +pub const LOOPBACK_MTU: usize = 255; + +/// MCTP transport header, which rides in front of the payload. The +/// fragment buffer has to hold both: a buffer of exactly the MTU makes the +/// router reject the send rather than truncate it. +const MCTP_HEADER_SIZE: usize = 4; + +/// One fragmented packet waiting to be fed back in, header included. +pub type Packet = heapless::Vec; + +/// Packets on their way from the router back to it. +/// +/// `DEPTH` bounds how many fragments may be in flight between drains. A +/// caller that drains after every send needs one; one that sends a whole +/// message first needs as many fragments as that message takes. +pub struct LoopbackQueue { + inner: RefCell>, +} + +struct Inner { + packets: Deque, + dropped: u32, +} + +impl Default for LoopbackQueue { + fn default() -> Self { + Self::new() + } +} + +impl LoopbackQueue { + /// An empty queue. + #[must_use] + pub const fn new() -> Self { + Self { + inner: RefCell::new(Inner { + packets: Deque::new(), + dropped: 0, + }), + } + } + + /// Takes the oldest queued packet, or `None` when none are waiting. + /// Feed what this returns to `Server::inbound`. + pub fn take(&self) -> Option { + self.inner.borrow_mut().packets.pop_front() + } + + /// How many packets are waiting. + #[must_use] + pub fn pending(&self) -> usize { + self.inner.borrow().packets.len() + } + + /// How many packets were discarded because the queue was full. + /// + /// A wire drops packets silently and MCTP is built to cope, so a full + /// queue is not an error here. It is still worth reporting, because in + /// this transport it means `DEPTH` is too small rather than that the + /// medium is lossy, and the test that follows will fail for a reason + /// that looks unrelated. + #[must_use] + pub fn dropped(&self) -> u32 { + self.inner.borrow().dropped + } +} + +/// The [`Sender`] half. Holds no packets of its own; everything goes to the +/// queue the caller kept. +pub struct LoopbackSender<'q, const DEPTH: usize> { + queue: &'q LoopbackQueue, +} + +impl<'q, const DEPTH: usize> LoopbackSender<'q, DEPTH> { + /// Lends `queue` to the router. + #[must_use] + pub const fn new(queue: &'q LoopbackQueue) -> Self { + Self { queue } + } +} + +impl Sender for LoopbackSender<'_, DEPTH> { + fn send_vectored(&mut self, mut fragmenter: Fragmenter, payload: &[&[u8]]) -> Result { + loop { + let mut buf = [0u8; LOOPBACK_MTU + MCTP_HEADER_SIZE]; + match fragmenter.fragment_vectored(payload, &mut buf) { + SendOutput::Packet(p) => { + let mut packet = Packet::new(); + if packet.extend_from_slice(p).is_err() { + // A fragment longer than the MTU we advertised. + return Err(Error::NoSpace); + } + let mut inner = self.queue.inner.borrow_mut(); + if inner.packets.push_back(packet).is_err() { + inner.dropped = inner.dropped.saturating_add(1); + } + } + SendOutput::Complete { tag, .. } => break Ok(tag), + SendOutput::Error { err, .. } => break Err(err), + } + } + } + + fn get_mtu(&self) -> usize { + LOOPBACK_MTU + } +} + +#[cfg(test)] +mod tests; diff --git a/services/mctp/transport-loopback/src/tests.rs b/services/mctp/transport-loopback/src/tests.rs new file mode 100644 index 000000000..2355141ed --- /dev/null +++ b/services/mctp/transport-loopback/src/tests.rs @@ -0,0 +1,137 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The queue contract, plus a round trip between two cross-wired servers. +//! +//! A `Fragmenter` is built by the router rather than by callers, so +//! `send_vectored` is exercised through real servers rather than called +//! directly. The two-server arrangement mirrors +//! `services/mctp/echo/tests/echo_host.rs`, which is where it comes from. + +use super::*; + +use mctp::Eid; +use openprot_mctp_server::Server; + +/// The two endpoints. Values are arbitrary; they only have to differ. +const EID_A: u8 = 8; +const EID_B: u8 = 42; + +/// Vendor-defined message type. Nothing here cares what the bytes mean. +const MSG_TYPE: u8 = 0x7E; + +/// Moves everything one side queued into the other side, the way a wire +/// would. Returns how many packets crossed. +fn transfer( + from: &LoopbackQueue, + to: &mut Server, +) -> usize { + let mut crossed = 0; + while let Some(pkt) = from.take() { + to.inbound(&pkt).expect("inbound accepts the packet"); + crossed += 1; + } + crossed +} + +#[test] +fn a_new_queue_has_nothing_in_it() { + let q: LoopbackQueue<4> = LoopbackQueue::new(); + assert_eq!(q.pending(), 0); + assert_eq!(q.dropped(), 0); +} + +#[test] +fn the_advertised_mtu_excludes_the_header() { + let q: LoopbackQueue<4> = LoopbackQueue::new(); + let s = LoopbackSender::new(&q); + assert_eq!(s.get_mtu(), LOOPBACK_MTU); + // The packets it queues carry the header on top of that payload. + assert_eq!(Packet::new().capacity(), LOOPBACK_MTU + MCTP_HEADER_SIZE); +} + +#[test] +fn taking_from_an_empty_queue_gives_nothing() { + let q: LoopbackQueue<4> = LoopbackQueue::new(); + assert!(q.take().is_none()); +} + +#[test] +fn a_message_crosses_from_one_server_to_the_other() { + let out_a: LoopbackQueue<16> = LoopbackQueue::new(); + let out_b: LoopbackQueue<16> = LoopbackQueue::new(); + let mut a: Server<_, 16> = Server::new(Eid(EID_A), 0, LoopbackSender::new(&out_a)); + let mut b: Server<_, 16> = Server::new(Eid(EID_B), 0, LoopbackSender::new(&out_b)); + + let listener = b.listener(MSG_TYPE).expect("B binds a listener"); + + let payload = b"openprot"; + let req = a.req(EID_B).expect("A opens a request to B"); + a.send(Some(req), MSG_TYPE, Some(EID_B), None, false, payload) + .expect("A sends"); + + assert!(transfer(&out_a, &mut b) > 0, "nothing crossed the wire"); + + let mut buf = [0u8; LOOPBACK_MTU]; + let meta = b.try_recv(listener, &mut buf).expect("B receives it"); + assert_eq!(&buf[..payload.len()], payload); + assert_eq!(meta.remote_eid, EID_A); +} + +#[test] +fn a_payload_longer_than_the_mtu_arrives_whole() { + let out_a: LoopbackQueue<16> = LoopbackQueue::new(); + let out_b: LoopbackQueue<16> = LoopbackQueue::new(); + let mut a: Server<_, 16> = Server::new(Eid(EID_A), 0, LoopbackSender::new(&out_a)); + let mut b: Server<_, 16> = Server::new(Eid(EID_B), 0, LoopbackSender::new(&out_b)); + + let listener = b.listener(MSG_TYPE).expect("B binds a listener"); + + // More than one fragment, so reassembly is actually exercised. + let payload: std::vec::Vec = (0..(LOOPBACK_MTU * 2) as u16).map(|i| i as u8).collect(); + let req = a.req(EID_B).expect("A opens a request to B"); + a.send(Some(req), MSG_TYPE, Some(EID_B), None, false, &payload) + .expect("A sends"); + + let fragments = transfer(&out_a, &mut b); + assert!(fragments > 1, "expected several fragments, got {fragments}"); + + let mut buf = [0u8; LOOPBACK_MTU * 3]; + b.try_recv(listener, &mut buf).expect("B receives it"); + assert_eq!(&buf[..payload.len()], &payload[..]); +} + +#[test] +fn the_reply_crosses_back() { + let out_a: LoopbackQueue<16> = LoopbackQueue::new(); + let out_b: LoopbackQueue<16> = LoopbackQueue::new(); + let mut a: Server<_, 16> = Server::new(Eid(EID_A), 0, LoopbackSender::new(&out_a)); + let mut b: Server<_, 16> = Server::new(Eid(EID_B), 0, LoopbackSender::new(&out_b)); + + let listener = b.listener(MSG_TYPE).expect("B binds a listener"); + let req = a.req(EID_B).expect("A opens a request to B"); + a.send(Some(req), MSG_TYPE, Some(EID_B), None, false, b"ping") + .expect("A sends"); + transfer(&out_a, &mut b); + + let mut buf = [0u8; LOOPBACK_MTU]; + let meta = b + .try_recv(listener, &mut buf) + .expect("B receives the request"); + + // A response carries no handle and reuses the request's tag. + b.send( + None, + MSG_TYPE, + Some(EID_A), + Some(meta.msg_tag), + false, + b"pong", + ) + .expect("B replies"); + assert!(transfer(&out_b, &mut a) > 0, "the reply did not cross"); + + let mut reply = [0u8; LOOPBACK_MTU]; + a.try_recv(req, &mut reply).expect("A receives the reply"); + assert_eq!(&reply[..4], b"pong"); +} From 684cf2f73e293f3da5b4b1e6198590359b04e6ef Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Sat, 3 Oct 2026 21:17:47 +0200 Subject: [PATCH 5/5] target/ast10x0: Add the mock BMC integration test First scenario of the QEMU black-box test: the RoT releases a managed device from reset, the device boots, and the RoT sees it report ready inside the window it allows. Two processes stand in for two chips. The wires between them are IPC channels rather than GPIO, because QEMU models one chip and both ends live inside it, so a GPIO block would connect to nothing. Commands go out on reset_cmd, evidence comes back on boot_evt. The split matters: EvidenceReader::read is synchronous and must not block, so the orchestrator holds a latch and the device pushes to it, rather than the orchestrator asking. The device's behaviour is openprot_mock_bmc, which is pin-generic and host-tested, wired here to two cells. What it costs is that the GPIO adapters in board/src/bmc.rs are swapped at the trait seam rather than exercised; the pin wiring stays with the hardware tests. App flash is the window between the kernel's flash and its RAM, 0x20500 to 0x60000, so 254.8 KB. Two apps at 128 KB fill it exactly. A third at that size is placed over kernel RAM and still builds, with only PMSAv7 MPU subregion overlap warnings to say the protection is wrong, so the PLDM firmware device needs the apps at 84 KB or less. The hardware card already runs pldm_fd at 64 KB. Assisted-by: Claude --- .../tests/integration/mock_bmc/BUILD.bazel | 112 ++++++++++++++ .../integration/mock_bmc/mock_bmc_main.rs | 142 ++++++++++++++++++ .../integration/mock_bmc/orchestrator_main.rs | 103 +++++++++++++ .../tests/integration/mock_bmc/system.json5 | 109 ++++++++++++++ .../tests/integration/mock_bmc/target.rs | 40 +++++ 5 files changed, 506 insertions(+) create mode 100644 target/ast10x0/tests/integration/mock_bmc/BUILD.bazel create mode 100644 target/ast10x0/tests/integration/mock_bmc/mock_bmc_main.rs create mode 100644 target/ast10x0/tests/integration/mock_bmc/orchestrator_main.rs create mode 100644 target/ast10x0/tests/integration/mock_bmc/system.json5 create mode 100644 target/ast10x0/tests/integration/mock_bmc/target.rs diff --git a/target/ast10x0/tests/integration/mock_bmc/BUILD.bazel b/target/ast10x0/tests/integration/mock_bmc/BUILD.bazel new file mode 100644 index 000000000..bd3e075c0 --- /dev/null +++ b/target/ast10x0/tests/integration/mock_bmc/BUILD.bazel @@ -0,0 +1,112 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@pigweed//pw_kernel/tooling:rust_app.bzl", "rust_app") +load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image", "system_image_test") +load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen") +load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script") +load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test") +load("@rules_rust//rust:defs.bzl", "rust_binary") +load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH") + +filegroup( + name = "system_config", + srcs = ["system.json5"], +) + +target_codegen( + name = "codegen", + arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m", + system_config = ":system_config", + target_compatible_with = TARGET_COMPATIBLE_WITH, +) + +target_linker_script( + name = "linker_script", + system_config = ":system_config", + tags = ["kernel"], + target_compatible_with = TARGET_COMPATIBLE_WITH, + template = "//target/ast10x0:linker_script_template", +) + +rust_binary( + name = "target", + srcs = ["target.rs"], + edition = "2024", + tags = ["kernel"], + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + ":codegen", + ":linker_script", + "//target/ast10x0:entry", + "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m", + "@pigweed//pw_kernel/kernel", + "@pigweed//pw_kernel/subsys/console:console_backend", + "@pigweed//pw_kernel/target:target_common", + "@pigweed//pw_kernel/userspace", + "@pigweed//pw_log/rust:pw_log", + ], +) + +# The managed device: drives its ready line in response to its reset line. +rust_app( + name = "mock_bmc", + srcs = ["mock_bmc_main.rs"], + codegen_crate_name = "app_mock_bmc", + edition = "2024", + system_config = ":system_config", + tags = ["kernel"], + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + "//hal/blocking", + "//services/test/mock-bmc:mock_bmc", + "@pigweed//pw_kernel/userspace", + "@pigweed//pw_log/rust:pw_log", + "@pigweed//pw_status/rust:pw_status", + "@rust_crates//:embedded-hal", + ], +) + +# The RoT: releases the device and judges whether it came up. +rust_app( + name = "orchestrator", + srcs = ["orchestrator_main.rs"], + codegen_crate_name = "app_orchestrator", + edition = "2024", + system_config = ":system_config", + tags = ["kernel"], + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + "@pigweed//pw_kernel/userspace", + "@pigweed//pw_log/rust:pw_log", + "@pigweed//pw_status/rust:pw_status", + ], +) + +system_image( + name = "mock_bmc_image", + apps = [ + ":mock_bmc", + ":orchestrator", + ], + kernel = ":target", + platform = "//target/ast10x0", + system_config = ":system_config", + tags = ["kernel"], + target_compatible_with = TARGET_COMPATIBLE_WITH, + visibility = ["//visibility:public"], +) + +# bazel test --config=virt_ast10x0 //target/ast10x0/tests/integration/mock_bmc:mock_bmc_qemu_test +system_image_test( + name = "mock_bmc_qemu_test", + image = ":mock_bmc_image", + tags = ["qemu_only"], + target_compatible_with = TARGET_COMPATIBLE_WITH, +) + +rust_binary_no_panics_test( + name = "no_panics_test", + binary = ":mock_bmc_image", + tags = ["kernel"], +) diff --git a/target/ast10x0/tests/integration/mock_bmc/mock_bmc_main.rs b/target/ast10x0/tests/integration/mock_bmc/mock_bmc_main.rs new file mode 100644 index 000000000..f1c43090f --- /dev/null +++ b/target/ast10x0/tests/integration/mock_bmc/mock_bmc_main.rs @@ -0,0 +1,142 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The managed device: reacts to its reset line, reports when it is ready. +//! +//! Stands in for the chip on the other end of two board traces. The reset +//! line arrives as a request on the `reset_cmd` channel and the ready line +//! leaves as a request on `boot_evt`, because QEMU models one chip and both +//! ends of those traces live inside it. +//! +//! The behaviour itself is `openprot_mock_bmc::MockBmc`, which is pin-generic +//! and host-tested. Here the pins are two cells: the reset cell is written by +//! the channel handler, and the ready cell is read after each poll to decide +//! whether the orchestrator needs telling. + +#![no_main] +#![no_std] + +use core::cell::Cell; + +use embedded_hal::digital::{ErrorType, InputPin, OutputPin}; +use openprot_hal_blocking::gpio_port::ActivePolarity; +use openprot_mock_bmc::{BootBehaviour, MockBmc}; +use userspace::syscall::Signals; +use userspace::time::{Clock, Instant, SystemClock}; +use userspace::{entry, syscall}; + +use app_mock_bmc::handle; + +/// How long this device takes to boot once reset is released. Well inside +/// any window the orchestrator waits, so the happy path is the happy path. +const BOOT_MILLIS: u64 = 50; + +/// How often the model is polled while it is booting. The delay is measured +/// from release rather than counted in polls, so this only bounds how late +/// the ready report can be, never whether it happens. +const POLL_MILLIS: u64 = 10; + +/// Reset is asserted low, the RoT_BMC_RESET_L convention. +const RESET_ASSERTED: u8 = 0; + +/// One line, shared between the model and the code that drives or watches +/// it. Single-threaded, so a `Cell` is enough. +#[derive(Clone, Copy)] +struct Line<'a>(&'a Cell); + +impl ErrorType for Line<'_> { + type Error = core::convert::Infallible; +} + +impl InputPin for Line<'_> { + fn is_high(&mut self) -> Result { + Ok(self.0.get()) + } + + fn is_low(&mut self) -> Result { + Ok(!self.0.get()) + } +} + +impl OutputPin for Line<'_> { + fn set_high(&mut self) -> Result<(), Self::Error> { + self.0.set(true); + Ok(()) + } + + fn set_low(&mut self) -> Result<(), Self::Error> { + self.0.set(false); + Ok(()) + } +} + +fn now_millis() -> u64 { + SystemClock::now().ticks() * 1000 / SystemClock::TICKS_PER_SEC +} + +fn millis_to_ticks(millis: u64) -> u64 { + millis * SystemClock::TICKS_PER_SEC / 1000 +} + +#[entry] +fn entry() { + // Reset starts asserted (low) and ready starts inactive, which is where + // a device sits before the RoT lets it go. + let reset = Cell::new(false); + let ready = Cell::new(false); + + let mut bmc = MockBmc::new( + Line(&reset), + ActivePolarity::ActiveLow, + Line(&ready), + ActivePolarity::ActiveHigh, + BootBehaviour::Boots { + after_millis: BOOT_MILLIS, + }, + ); + + if syscall::wait_group_add(handle::WG, handle::RESET_CMD, Signals::READABLE, 0usize).is_err() { + loop {} + } + + let mut reported = false; + let mut cmd = [0u8; 1]; + + loop { + // Wake on a reset command or on the poll interval, whichever comes + // first. Nothing here blocks on the orchestrator. + let deadline = + Instant::from_ticks(SystemClock::now().ticks() + millis_to_ticks(POLL_MILLIS)); + let _ = syscall::object_wait(handle::WG, Signals::READABLE, deadline); + + if let Ok(len) = syscall::channel_read(handle::RESET_CMD, 0usize, &mut cmd) { + if len == 1usize { + let asserted = cmd[0] == RESET_ASSERTED; + // Active low: asserted means the line is driven low. + reset.set(!asserted); + if asserted { + reported = false; + } + } + let _ = syscall::channel_respond(handle::RESET_CMD, &[0u8; 0]); + } + + if bmc.poll(now_millis()).is_err() { + let _ = syscall::debug_shutdown(Err(pw_status::Error::Internal)); + } + + // The ready line going active is the whole message. Report it once + // per boot; a reset clears the flag so the next boot reports again. + if ready.get() && !reported { + let mut ack = [0u8; 1]; + if syscall::channel_transact(handle::BOOT_EVT, &[1u8], &mut ack, Instant::MAX).is_ok() { + reported = true; + } + } + } +} + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} +} diff --git a/target/ast10x0/tests/integration/mock_bmc/orchestrator_main.rs b/target/ast10x0/tests/integration/mock_bmc/orchestrator_main.rs new file mode 100644 index 000000000..dbc44ee56 --- /dev/null +++ b/target/ast10x0/tests/integration/mock_bmc/orchestrator_main.rs @@ -0,0 +1,103 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The RoT side of the mock-BMC integration test. +//! +//! Scenario: release the managed device from reset, wait for it to report +//! ready, and fail if it never does. That is the shape every boot-sequence +//! scenario has; the richer ones differ in what the device does and what +//! deadline the RoT allows, not in this structure. +//! +//! Evidence arrives as a request on `boot_evt` and is latched here. The +//! orchestrator never asks the device whether it is up, because +//! `EvidenceReader::read` is synchronous and may not block. Reading a latch +//! is the only shape that satisfies that, and it matches the contract that +//! evidence is cleared by the reset path rather than by the reader. + +#![no_main] +#![no_std] + +use userspace::syscall::Signals; +use userspace::time::{Clock, Instant, SystemClock}; +use userspace::{entry, syscall}; + +use app_orchestrator::handle; + +/// How long the RoT is willing to wait for the device to report ready. The +/// device boots in 50 ms, so this is slack, not a race. +const BOOT_WINDOW_MILLIS: u64 = 2_000; + +/// Reset command bytes, read by the device as its reset line. +const RESET_ASSERT: u8 = 0; +const RESET_RELEASE: u8 = 1; + +fn millis_to_ticks(millis: u64) -> u64 { + millis * SystemClock::TICKS_PER_SEC / 1000 +} + +fn deadline_in(millis: u64) -> Instant { + Instant::from_ticks(SystemClock::now().ticks() + millis_to_ticks(millis)) +} + +/// Drives the device's reset line. Returns an error if the command could +/// not be delivered, which is a wiring failure rather than a boot failure. +fn drive_reset(level: u8) -> Result<(), ()> { + let mut ack = [0u8; 1]; + syscall::channel_transact(handle::RESET_CMD, &[level], &mut ack, deadline_in(1_000)) + .map(|_| ()) + .map_err(|_| ()) +} + +fn run() -> Result<(), ()> { + syscall::wait_group_add(handle::WG, handle::BOOT_EVT, Signals::READABLE, 0usize) + .map_err(|_| ())?; + + // Hold the device in reset, then let it go. Asserting first means the + // test does not depend on what the line happened to be at boot. + drive_reset(RESET_ASSERT)?; + drive_reset(RESET_RELEASE)?; + + // Latch the ready report. A real reader would return this latch from + // EvidenceReader::read without blocking; here the assertion is the + // latch itself being set before the window closes. + let mut booted = false; + let window = deadline_in(BOOT_WINDOW_MILLIS); + let mut evt = [0u8; 1]; + + while !booted { + if syscall::object_wait(handle::WG, Signals::READABLE, window).is_err() { + // The window closed with no report: the device never came up. + pw_log::error!("mock BMC never reported ready"); + return Err(()); + } + + if let Ok(len) = syscall::channel_read(handle::BOOT_EVT, 0usize, &mut evt) { + let _ = syscall::channel_respond(handle::BOOT_EVT, &[0u8; 0]); + if len == 1usize && evt[0] == 1 { + booted = true; + } + } + } + + pw_log::info!("mock BMC reported ready"); + Ok(()) +} + +#[entry] +fn entry() { + match run() { + Ok(()) => { + let _ = syscall::debug_shutdown(Ok(())); + } + Err(()) => { + let _ = syscall::debug_shutdown(Err(pw_status::Error::Internal)); + } + } + #[expect(clippy::empty_loop)] + loop {} +} + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} +} diff --git a/target/ast10x0/tests/integration/mock_bmc/system.json5 b/target/ast10x0/tests/integration/mock_bmc/system.json5 new file mode 100644 index 000000000..a1b850cb8 --- /dev/null +++ b/target/ast10x0/tests/integration/mock_bmc/system.json5 @@ -0,0 +1,109 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +// AST10x0 mock-BMC integration QEMU test. +// +// Two processes standing in for two chips: +// +// orchestrator — drives the reset line, latches boot evidence +// mock_bmc — the managed device: reacts to reset, reports ready +// +// The two wires are IPC channels, not GPIO. QEMU models one chip, and both +// ends live inside it, so a GPIO block would connect to nothing. Commands +// go out orchestrator -> mock_bmc; evidence comes back the other way on its +// own channel, because EvidenceReader::read is synchronous and must not +// block, so the orchestrator has to hold a latch rather than ask. +// +// Memory map (AST10x0: 768 KB SRAM, no XIP, so code and data share it): +// 0x00000000 - 0x00000500 vector table (1280 B) +// 0x00000500 - 0x00020500 kernel flash (~127 KB) +// 0x00020500 - 0x00060500 app flash (256 KB total; 128 KB per app) +// 0x00060000 - 0x00080000 kernel RAM (128 KB) +// 0x00080000 - 0x000C0000 app RAM (256 KB region; 32 KB per process used) +// +// App flash is the window between the kernel's flash and the kernel's RAM, +// 0x20500 to 0x60000, which is 254.8 KB. Two apps at 128 KB fill it exactly. +// A third app does not fit at that size: the linker places it over kernel RAM +// and the build still succeeds, with only PMSAv7 MPU subregion overlap +// warnings to say the protection is wrong. Three apps need 84 KB each, four +// need 63 KB. The hardware firmware-update card already runs pldm_fd and +// i2c_server at 64 KB, so that is a normal size rather than a squeeze. +{ + arch: { + type: "armv7m", + vector_table_start_address: 0x00000000, + vector_table_size_bytes: 1280, + }, + kernel: { + flash_start_address: 0x00000500, + flash_size_bytes: 129792, + ram_start_address: 0x00060000, + ram_size_bytes: 131072, + }, + apps: [ + { + name: "mock_bmc", + flash_size_bytes: 131072, + processes: [ + { + name: "mock_bmc_process", + ram_size_bytes: 32768, + objects: [ + { + name: "wg", + type: "wait_group", + }, + { + // Orchestrator -> device: assert or release reset. + name: "reset_cmd", + type: "channel_handler", + }, + { + // Device -> orchestrator: the ready line changed. + name: "boot_evt", + type: "channel_initiator", + handler_process: "orchestrator_process", + handler_object_name: "boot_evt", + }, + { + type: "thread", + name: "mock_bmc_thread", + kernel_stack_size_bytes: 4096, + }, + ], + }, + ], + }, + { + name: "orchestrator", + flash_size_bytes: 131072, + processes: [ + { + name: "orchestrator_process", + ram_size_bytes: 32768, + objects: [ + { + name: "wg", + type: "wait_group", + }, + { + name: "boot_evt", + type: "channel_handler", + }, + { + name: "reset_cmd", + type: "channel_initiator", + handler_process: "mock_bmc_process", + handler_object_name: "reset_cmd", + }, + { + type: "thread", + name: "orchestrator_thread", + kernel_stack_size_bytes: 4096, + }, + ], + }, + ], + }, + ], +} diff --git a/target/ast10x0/tests/integration/mock_bmc/target.rs b/target/ast10x0/tests/integration/mock_bmc/target.rs new file mode 100644 index 000000000..347499928 --- /dev/null +++ b/target/ast10x0/tests/integration/mock_bmc/target.rs @@ -0,0 +1,40 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Kernel target for the mock-BMC integration QEMU test. +//! +//! The orchestrator app reports the result with +//! `syscall::debug_shutdown(Ok(()) | Err(..))`, which lands here and writes +//! the UART sentinel qemu_runner.py greps for. + +#![no_std] +#![no_main] + +use console_backend::console_backend_write_all; +use entry as _; +use target_common::{declare_target, TargetInterface}; + +pub struct Target {} + +impl TargetInterface for Target { + const NAME: &'static str = "AST10x0 mock-BMC integration test"; + + fn main() -> ! { + codegen::start(); + #[expect(clippy::empty_loop)] + loop {} + } + + fn shutdown(code: u32) -> ! { + let sentinel: &[u8] = if code == 0 { + b"TEST_RESULT:PASS\n" + } else { + b"TEST_RESULT:FAIL\n" + }; + let _ = console_backend_write_all(sentinel); + #[expect(clippy::empty_loop)] + loop {} + } +} + +declare_target!(Target);