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() 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"); +}