[bazel] add rule / macro to create multislot flash binaries

This adds a Bazel macro (`opentitan_multislot_flash_binary`) and custom
rule (`assemble_flash_image`) to build OpenTitan flash images with
multiple slots / stages filled for simple E2E testing.

This partially addresses #13511.

Signed-off-by: Timothy Trippel <ttrippel@google.com>
diff --git a/rules/opentitan.bzl b/rules/opentitan.bzl
index a460300..9601f62 100644
--- a/rules/opentitan.bzl
+++ b/rules/opentitan.bzl
@@ -42,6 +42,11 @@
     "fpga_cw310": ["@//sw/device/lib/arch:fpga_cw310"],
 }
 
+# Default keys used to sign ROM_EXT and Owner images for testing.
+DEFAULT_SIGNING_KEYS = {
+    "test_key_0": "@//sw/device/silicon_creator/rom/keys:test_private_key_0",
+}
+
 def _obj_transform_impl(ctx):
     cc_toolchain = find_cc_toolchain(ctx).cc
     outputs = []
@@ -516,6 +521,49 @@
     },
 )
 
+def _assemble_flash_image_impl(ctx):
+    output = ctx.actions.declare_file(ctx.attr.output)
+    outputs = [output]
+    inputs = []
+    arguments = [
+        "image",
+        "assemble",
+        "--mirror",
+        "false",
+        "--output",
+        output.path,
+        "--size",
+        ctx.attr.image_size,
+    ]
+    for binary, offset in ctx.attr.binaries.items():
+        inputs.extend(binary.files.to_list())
+        arguments.append("{}@{}".format(binary.files.to_list()[0].path, offset))
+    ctx.actions.run(
+        outputs = outputs,
+        inputs = inputs,
+        arguments = arguments,
+        executable = ctx.executable._opentitantool,
+    )
+    return [DefaultInfo(
+        files = depset(outputs),
+        data_runfiles = ctx.runfiles(files = outputs),
+    )]
+
+assemble_flash_image = rv_rule(
+    implementation = _assemble_flash_image_impl,
+    attrs = {
+        "image_size": attr.string(),
+        "output": attr.string(),
+        "binaries": attr.label_keyed_string_dict(allow_empty = False),
+        "_opentitantool": attr.label(
+            default = "//sw/host/opentitantool:opentitantool",
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
+
 def opentitan_binary(
         name,
         platform = OPENTITAN_PLATFORM,
@@ -652,10 +700,7 @@
           Containing all targets for a single device for the above generated rules.
         filegroup                           named: <name>
           Containing all targets across all devices for the above generated rules.
-    Returns:
-      List of targets generated by all the above rules (except the filegroup).
     """
-
     deps = kwargs.pop("deps", [])
     all_targets = []
     for (device, dev_deps) in PER_DEVICE_DEPS.items():
@@ -726,12 +771,98 @@
     toolchains = ["@rules_cc//cc:toolchain_type"],
 )
 
+def opentitan_multislot_flash_binary(
+        name,
+        srcs,
+        image_size,
+        platform = OPENTITAN_PLATFORM):
+    """A helper macro for generating multislot OpenTitan binary flash images.
+
+    This macro is mostly a wrapper around the `assemble_flash_image` rule, that
+    invokes `opentitantool` to stitch together multiple `opentitan_flash_binary`
+    images to create a single image for bootstrapping. This enables efficient
+    testing by only requiring one boostrap operation to load both silicon
+    creator and owner stages of flash.
+    Args:
+      @param name: The name of this rule.
+      @param srcs: A dictionary of `opentitan_flash_binary` targets (to stitch
+                   together) as keys, and key/offset configurations as values.
+      @param image_size: The final flash image_size to pass to `opentitantool`.
+      @param platform: The target platform for the artifacts.
+    Emits rules:
+      For each device in per_device_deps entry:
+        rules emitted by `opentitan_binary` named: see `opentitan_binary` macro
+        assemble_flash_image named: <name>_<device>_bin_signed
+        bin_to_vmem          named: <name>_<device>_vmem64_signed
+        scrambled_flash_vmem named: <name>_<device>_scr_vmem64_signed
+        filegroup            named: <name>_<device>
+          Containing all targets for a single device for the above generated rules.
+        filegroup            named: <name>
+          Containing all targets across all devices for the above generated rules.
+    """
+    all_targets = []
+    for (device, _) in PER_DEVICE_DEPS.items():
+        devname = "{}_{}".format(name, device)
+        dev_targets = []
+        signed_dev_binaries = {}
+        for src, configs in srcs.items():
+            if "key" not in configs:
+                fail("Missing signing key for binary: {}".format(src))
+            if "offset" not in configs:
+                fail("Missing offset for binary: {}".format(src))
+            signed_dev_binary = "{}_{}_bin_signed_{}".format(
+                src,
+                device,
+                configs["key"],
+            )
+            signed_dev_binaries[signed_dev_binary] = configs["offset"]
+
+        # Assemble the signed binaries into a single binary.
+        signed_bin_name = "{}_bin_signed".format(devname)
+        dev_targets.append(":" + signed_bin_name)
+        assemble_flash_image(
+            name = signed_bin_name,
+            output = "{}.signed.bin".format(devname),
+            image_size = image_size,
+            binaries = signed_dev_binaries,
+        )
+
+        # Generate a VMEM64 from the binary.
+        signed_vmem_name = "{}_vmem64_signed".format(devname)
+        dev_targets.append(":" + signed_vmem_name)
+        bin_to_vmem(
+            name = signed_vmem_name,
+            bin = signed_bin_name,
+            platform = platform,
+            word_size = 64,  # Backdoor-load VMEM image uses 64-bit words
+        )
+
+        # Scramble signed VMEM64.
+        scr_signed_vmem_name = "{}_scr_vmem64_signed".format(devname)
+        dev_targets.append(":" + scr_signed_vmem_name)
+        scramble_flash_vmem(
+            name = scr_signed_vmem_name,
+            vmem = signed_vmem_name,
+            platform = platform,
+        )
+
+        # Create a filegroup with just the current device's targets.
+        native.filegroup(
+            name = devname,
+            srcs = dev_targets,
+        )
+        dev_targets.extend(dev_targets)
+
+    # Create a filegroup with all assembled flash images.
+    native.filegroup(
+        name = name,
+        srcs = all_targets,
+    )
+
 def opentitan_flash_binary(
         name,
         platform = OPENTITAN_PLATFORM,
-        signing_keys = {
-            "test_key_0": "@//sw/device/silicon_creator/rom/keys:test_private_key_0",
-        },
+        signing_keys = DEFAULT_SIGNING_KEYS,
         signed = False,
         manifest = None,
         **kwargs):
@@ -762,10 +893,7 @@
           Containing all targets for a single device for the above generated rules.
         filegroup              named: <name>
           Containing all targets across all devices for the above generated rules.
-    Returns:
-      List of targets generated by all the above rules (except the filegroup).
     """
-
     deps = kwargs.pop("deps", [])
     all_targets = []
     for (device, dev_deps) in PER_DEVICE_DEPS.items():
diff --git a/rules/opentitan_test.bzl b/rules/opentitan_test.bzl
index caf6c4e..385a729 100644
--- a/rules/opentitan_test.bzl
+++ b/rules/opentitan_test.bzl
@@ -389,7 +389,13 @@
         else:
             flash = "{}_{}_bin".format(ot_flash_binary, target)
         if signed:
-            flash += "_signed_{}".format(key)
+            flash += "_signed"
+
+            # Multislot flash binaries could have different slots / stages
+            # signed with different keys. Therefore, the key name will not be
+            # part of the target name for such images.
+            if key != "multislot":
+                flash += "_{}".format(key)
 
         # If test is to be run in ROM we load the same image into flash as a
         # as a placeholder (since execution will never reach flash). Moreover,