diff --git a/Cargo.lock b/Cargo.lock index 5fa20ffc28..66ddf118bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,6 +2296,7 @@ dependencies = [ "flowey_lib_common", "igvmfilegen_config", "log", + "opentmk_disk", "paste", "powershell_builder", "serde", @@ -5549,6 +5550,7 @@ dependencies = [ "memory_range", "minimal_rt", "nostd_spin_channel", + "opentmk_protocol", "serde", "serde_json", "spin", @@ -5558,6 +5560,30 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "opentmk_disk" +version = "0.0.0" +dependencies = [ + "anyhow", + "disk_vhd1", + "fatfs", + "fscommon", + "gptman", + "guid", + "opentmk_protocol", + "tempfile", +] + +[[package]] +name = "opentmk_protocol" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.16", + "zerocopy", +] + [[package]] name = "openvmm" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 96fd973c8a..8a9bb080c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,10 @@ edition = "2024" [workspace.dependencies] xtask_fuzz = { path = "xtask/xtask_fuzz" } +# opentmk +opentmk_protocol = { path = "opentmk_protocol" } +opentmk_disk = { path = "opentmk/opentmk_disk" } + flowey = { path = "flowey/flowey" } flowey_cli = { path = "flowey/flowey_cli" } flowey_core = { path = "flowey/flowey_core" } diff --git a/flowey/flowey_hvlite/src/pipelines/build_opentmk.rs b/flowey/flowey_hvlite/src/pipelines/build_opentmk.rs new file mode 100644 index 0000000000..6ad9781b8e --- /dev/null +++ b/flowey/flowey_hvlite/src/pipelines/build_opentmk.rs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! See [`BuildOpentmkCli`] + +use crate::pipelines::build_igvm::bail_if_running_in_ci; +use crate::pipelines_shared::cfg_common_params::CommonArchCli; +use flowey::node::prelude::ReadVar; +use flowey::pipeline::prelude::*; +use flowey_lib_hvlite::common::CommonArch; +use std::path::PathBuf; + +/// Build OpenTMK and package it into a bootable VHD. +#[derive(clap::Args)] +pub struct BuildOpentmkCli { + /// Target architecture for the OpenTMK build. Defaults to x86-64. + #[clap(default_value = "x86-64")] + pub arch: CommonArchCli, + + /// Custom name for the output `.efi`, `.vhd`, and `.pdb` (default `opentmk`). + #[clap(long)] + pub name: Option, + + /// Path to a JSON test-selection config to embed in the built VHD. If + /// omitted, the VHD boots the unconfigured `opentmk.efi`. + #[clap(long)] + pub config: Option, + + /// Build using release profile. + #[clap(long)] + pub release: bool, + + /// Directory for the output artifacts. + #[clap(long, default_value = "flowey-out/build-opentmk")] + pub dir: PathBuf, + + /// pass `--verbose` to cargo + #[clap(long)] + pub verbose: bool, + + /// pass `--locked` to cargo + #[clap(long)] + pub locked: bool, + + /// Automatically install any missing required dependencies. + #[clap(long)] + pub install_missing_deps: bool, +} + +impl IntoPipeline for BuildOpentmkCli { + fn into_pipeline(self, backend_hint: PipelineBackendHint) -> anyhow::Result { + if !matches!(backend_hint, PipelineBackendHint::Local) { + anyhow::bail!("build-opentmk is for local use only") + } + + bail_if_running_in_ci()?; + + let openvmm_repo = flowey_lib_common::git_checkout::RepoSource::ExistingClone( + ReadVar::from_static(crate::repo_root()), + ); + + let Self { + arch, + name, + config, + release, + dir, + verbose, + locked, + install_missing_deps, + } = self; + + let arch: CommonArch = arch.into(); + + std::fs::create_dir_all(&dir)?; + + let mut pipeline = Pipeline::new(); + + pipeline + .new_job( + FlowPlatform::host(backend_hint), + FlowArch::host(backend_hint), + "build-opentmk", + ) + .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init) + .dep_on( + |_| flowey_lib_hvlite::_jobs::cfg_hvlite_reposource::Params { + hvlite_repo_source: openvmm_repo, + }, + ) + .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_common::Params { + local_only: Some(flowey_lib_hvlite::_jobs::cfg_common::LocalOnlyParams { + interactive: true, + auto_install: install_missing_deps, + ignore_rust_version: true, + }), + verbose: ReadVar::from_static(verbose), + locked, + deny_warnings: false, + no_incremental: false, + }) + .dep_on( + |ctx| flowey_lib_hvlite::_jobs::local_build_opentmk::Params { + artifact_dir: ReadVar::from_static(dir), + done: ctx.new_done_handle(), + arch, + release, + name, + config, + }, + ) + .finish(); + + Ok(pipeline) + } +} diff --git a/flowey/flowey_hvlite/src/pipelines/mod.rs b/flowey/flowey_hvlite/src/pipelines/mod.rs index 101f7adf9e..6e341b9c71 100644 --- a/flowey/flowey_hvlite/src/pipelines/mod.rs +++ b/flowey/flowey_hvlite/src/pipelines/mod.rs @@ -8,6 +8,7 @@ use vmm_tests_run::VmmTestsRunCli; pub mod build_docs; pub mod build_igvm; +pub mod build_opentmk; pub mod build_reproducible; pub mod cca_tests; pub mod checkin_gates; @@ -26,6 +27,8 @@ pub enum OpenvmmPipelines { }, BuildIgvm(build_igvm::BuildIgvmCli), + /// Build OpenTMK and package it into a bootable VHD + BuildOpentmk(build_opentmk::BuildOpentmkCli), BuildReproducible(build_reproducible::BuildReproducibleCli), CustomVmfirmwareigvmDll(custom_vmfirmwareigvm_dll::CustomVmfirmwareigvmDllCli), @@ -61,6 +64,7 @@ impl IntoPipeline for OpenvmmPipelines { std::process::exit(status.code().unwrap_or(-1)); } OpenvmmPipelines::BuildIgvm(cmd) => cmd.into_pipeline(pipeline_hint), + OpenvmmPipelines::BuildOpentmk(cmd) => cmd.into_pipeline(pipeline_hint), OpenvmmPipelines::BuildReproducible(cmd) => cmd.into_pipeline(pipeline_hint), OpenvmmPipelines::CustomVmfirmwareigvmDll(cmd) => cmd.into_pipeline(pipeline_hint), OpenvmmPipelines::Ci(cmd) => match cmd { diff --git a/flowey/flowey_lib_hvlite/Cargo.toml b/flowey/flowey_lib_hvlite/Cargo.toml index e4fe7e16b0..b7a5c40be5 100644 --- a/flowey/flowey_lib_hvlite/Cargo.toml +++ b/flowey/flowey_lib_hvlite/Cargo.toml @@ -12,6 +12,7 @@ flowey_lib_common.workspace = true powershell_builder.workspace = true igvmfilegen_config.workspace = true +opentmk_disk.workspace = true vmm_test_images = { workspace = true, features = ["serde"] } anyhow.workspace = true diff --git a/flowey/flowey_lib_hvlite/src/_jobs/local_build_opentmk.rs b/flowey/flowey_lib_hvlite/src/_jobs/local_build_opentmk.rs new file mode 100644 index 0000000000..2a08d70c8f --- /dev/null +++ b/flowey/flowey_lib_hvlite/src/_jobs/local_build_opentmk.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A local-only job that supports the `cargo xflowey build-opentmk` CLI + +use flowey::node::prelude::*; + +flowey_request! { + pub struct Params { + pub artifact_dir: ReadVar, + pub done: WriteVar, + + pub arch: crate::common::CommonArch, + pub release: bool, + /// Custom name for the output `.efi`, `.vhd`, and `.pdb`. Defaults to "opentmk". + pub name: Option, + /// Optional JSON test-selection config to embed in the built VHD. + pub config: Option, + } +} + +new_simple_flow_node!(struct Node); + +impl SimpleFlowNode for Node { + type Request = Params; + + fn imports(ctx: &mut ImportCtx<'_>) { + ctx.import::(); + } + + fn process_request(request: Self::Request, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> { + let Params { + artifact_dir, + done, + arch, + release, + name, + config, + } = request; + + let profile = if release { + crate::common::CommonProfile::Release + } else { + crate::common::CommonProfile::Debug + }; + + let name = name.unwrap_or_else(|| "opentmk".to_string()); + + let opentmk_output = ctx.reqv(|v| crate::build_opentmk::Request { + arch, + profile, + out_name: Some(name.clone()), + opentmk: v, + }); + + ctx.emit_rust_step("package opentmk into VHD", |ctx| { + done.claim(ctx); + let artifact_dir = artifact_dir.claim(ctx); + let opentmk_output = opentmk_output.claim(ctx); + move |rt| { + let crate::build_opentmk::OpentmkOutput { efi, pdb } = rt.read(opentmk_output); + + let output_dir = rt.read(artifact_dir); + let output_dir = output_dir.absolute()?; + fs_err::create_dir_all(&output_dir)?; + + // Build the bootable VHD, patching in the test config if one was given. + let disk_arch = match arch { + crate::common::CommonArch::X86_64 => opentmk_disk::Arch::X86_64, + crate::common::CommonArch::Aarch64 => opentmk_disk::Arch::Aarch64, + }; + let efi_bytes = fs_err::read(&efi)?; + let vhd_path = output_dir.join(format!("{name}.vhd")); + let image = if let Some(cfg_path) = &config { + let config_json = fs_err::read(cfg_path.absolute()?)?; + opentmk_disk::build_opentmk_vhd_with_config( + &efi_bytes, + disk_arch, + &config_json, + )? + } else { + opentmk_disk::build_opentmk_vhd(&efi_bytes, disk_arch)? + }; + if vhd_path.exists() { + fs_err::remove_file(&vhd_path)?; + } + image.persist(&vhd_path)?; + + fs_err::copy(&efi, output_dir.join(format!("{name}.efi")))?; + fs_err::copy(&pdb, output_dir.join(format!("{name}.pdb")))?; + + log::info!("EFI: {}", output_dir.join(format!("{name}.efi")).display()); + log::info!("VHD: {}", vhd_path.display()); + log::info!("PDB: {}", output_dir.join(format!("{name}.pdb")).display()); + + Ok(()) + } + }); + + Ok(()) + } +} diff --git a/flowey/flowey_lib_hvlite/src/_jobs/mod.rs b/flowey/flowey_lib_hvlite/src/_jobs/mod.rs index be79bee33d..988200c9e3 100644 --- a/flowey/flowey_lib_hvlite/src/_jobs/mod.rs +++ b/flowey/flowey_lib_hvlite/src/_jobs/mod.rs @@ -24,6 +24,7 @@ pub mod consume_and_test_nextest_unit_tests_archive; pub mod consume_and_test_nextest_vmm_tests_archive; pub mod local_build_and_run_nextest_vmm_tests; pub mod local_build_igvm; +pub mod local_build_opentmk; pub mod local_check_cca_emu_prereq; pub mod local_custom_vmfirmwareigvm_dll; pub mod local_install_cca_emu; diff --git a/flowey/flowey_lib_hvlite/src/build_opentmk.rs b/flowey/flowey_lib_hvlite/src/build_opentmk.rs new file mode 100644 index 0000000000..2fabaf99d3 --- /dev/null +++ b/flowey/flowey_lib_hvlite/src/build_opentmk.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Build `opentmk` UEFI binaries + +use crate::common::CommonArch; +use crate::common::CommonProfile; +use flowey::node::prelude::*; +use flowey_lib_common::run_cargo_build::CargoCrateType; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize)] +pub struct OpentmkOutput { + pub efi: PathBuf, + pub pdb: PathBuf, +} + +impl Artifact for OpentmkOutput {} + +flowey_request! { + pub struct Request { + pub arch: CommonArch, + pub profile: CommonProfile, + /// Custom output `.efi`/`.pdb` name. Defaults to "opentmk". + pub out_name: Option, + pub opentmk: WriteVar, + } +} + +new_flow_node!(struct Node); + +impl FlowNode for Node { + type Request = Request; + + fn imports(ctx: &mut ImportCtx<'_>) { + ctx.import::(); + } + + fn emit(requests: Vec, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> { + let mut tasks: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + for Request { + arch, + profile, + out_name, + opentmk, + } in requests + { + let out_name = out_name.unwrap_or_else(|| "opentmk".to_string()); + tasks + .entry((arch, profile, out_name)) + .or_default() + .push(opentmk); + } + + for ((arch, profile, out_name), outvars) in tasks { + let output = ctx.reqv(|v| crate::run_cargo_build::Request { + crate_name: "opentmk".into(), + out_name: out_name.clone(), + crate_type: CargoCrateType::Bin, + profile: profile.into(), + features: Default::default(), + target: target_lexicon::Triple { + architecture: arch.as_arch(), + operating_system: target_lexicon::OperatingSystem::Uefi, + environment: target_lexicon::Environment::Unknown, + vendor: target_lexicon::Vendor::Unknown, + // work around bug in target_lexicon (this shouldn't be Elf) + binary_format: target_lexicon::BinaryFormat::Elf, + }, + no_split_dbg_info: false, + extra_env: None, + pre_build_deps: Vec::new(), + output: v, + }); + + ctx.emit_minor_rust_step("report built opentmk", |ctx| { + let outvars = outvars.claim(ctx); + let output = output.claim(ctx); + move |rt| { + let (efi, pdb) = match rt.read(output) { + crate::run_cargo_build::CargoBuildOutput::UefiBin { efi, pdb } => { + (efi, pdb) + } + _ => unreachable!(), + }; + + let output = OpentmkOutput { efi, pdb }; + + for var in outvars { + rt.write(var, &output); + } + } + }); + } + + Ok(()) + } +} diff --git a/flowey/flowey_lib_hvlite/src/lib.rs b/flowey/flowey_lib_hvlite/src/lib.rs index b9d4afa76f..c2a41d04df 100644 --- a/flowey/flowey_lib_hvlite/src/lib.rs +++ b/flowey/flowey_lib_hvlite/src/lib.rs @@ -20,6 +20,7 @@ pub mod build_ohcldiag_dev; pub mod build_openhcl_boot; pub mod build_openhcl_igvm_from_recipe; pub mod build_openhcl_initrd; +pub mod build_opentmk; pub mod build_openvmm; pub mod build_openvmm_hcl; pub mod build_openvmm_vhost; diff --git a/opentmk/Cargo.toml b/opentmk/Cargo.toml index 45daa71d37..ab1d300698 100644 --- a/opentmk/Cargo.toml +++ b/opentmk/Cargo.toml @@ -14,6 +14,7 @@ hvdef.workspace = true log.workspace = true memory_range.workspace = true minimal_rt.workspace = true +opentmk_protocol.workspace = true spin.workspace = true serde = { workspace = true, features = ["derive"]} serde_json = { workspace = true, features = ["alloc"] } diff --git a/opentmk/opentmk_disk/Cargo.toml b/opentmk/opentmk_disk/Cargo.toml new file mode 100644 index 0000000000..d2b27e2192 --- /dev/null +++ b/opentmk/opentmk_disk/Cargo.toml @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "opentmk_disk" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +opentmk_protocol.workspace = true + +anyhow.workspace = true +disk_vhd1.workspace = true +fatfs = { workspace = true, features = ["std", "alloc"] } +fscommon.workspace = true +gptman.workspace = true +guid.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/opentmk/opentmk_disk/src/lib.rs b/opentmk/opentmk_disk/src/lib.rs new file mode 100644 index 0000000000..55c580728a --- /dev/null +++ b/opentmk/opentmk_disk/src/lib.rs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Build a bootable OpenTMK UEFI disk image for the flowey `build-opentmk` path. +//! [`build_opentmk_vhd`] builds as-is; [`build_opentmk_vhd_with_config`] patches [`opentmk_protocol::TestConfig`]. + +use anyhow::Context; +use guid::Guid; +use std::io::Read; +use std::io::Seek; +use std::io::Write; +use std::ops::Range; + +const SECTOR_SIZE: u64 = 512; +const IMAGE_SIZE: u64 = 64 * 1024 * 1024; + +/// EFI System Partition type GUID. +const EFI_SYSTEM_PARTITION_GUID: Guid = guid::guid!("C12A7328-F81F-11D2-BA4B-00A0C93EC93B"); + +/// Target architecture for the boot image, selecting the removable-media path. +#[derive(Debug, Clone, Copy)] +pub enum Arch { + /// x86_64 (`EFI/BOOT/BOOTX64.EFI`). + X86_64, + /// aarch64 (`EFI/BOOT/BOOTAA64.EFI`). + Aarch64, +} + +/// Build a fixed VHD that boots the given OpenTMK `.efi` as-is. +pub fn build_opentmk_vhd(efi: &[u8], arch: Arch) -> anyhow::Result { + build_image(efi, arch) +} + +/// Build a fixed VHD that boots a copy of `efi` with `config_json` patched into +/// its embedded config region, selecting which test to run. +pub fn build_opentmk_vhd_with_config( + efi: &[u8], + arch: Arch, + config_json: &[u8], +) -> anyhow::Result { + let mut efi = efi.to_vec(); + opentmk_protocol::patch_opentmk_config(&mut efi, config_json) + .context("failed to patch opentmk test config")?; + build_image(&efi, arch) +} + +fn build_image(efi: &[u8], arch: Arch) -> anyhow::Result { + let boot_path = match arch { + Arch::X86_64 => "EFI/BOOT/BOOTX64.EFI", + Arch::Aarch64 => "EFI/BOOT/BOOTAA64.EFI", + }; + let mut image_file = tempfile::Builder::new().suffix(".vhd").tempfile()?; + image_file + .as_file() + .set_len(IMAGE_SIZE) + .context("failed to set file size")?; + + build_fat32_disk_image(&mut image_file, "ESP", b"ESP ", &[(boot_path, efi)])?; + + disk_vhd1::Vhd1Disk::make_fixed(image_file.as_file()) + .context("failed to make vhd for uefi boot image")?; + Ok(image_file) +} + +fn build_fat32_disk_image( + file: &mut (impl Read + Write + Seek), + gpt_name: &str, + volume_label: &[u8; 11], + files: &[(&str, &[u8])], +) -> anyhow::Result<()> { + let range = build_gpt(file, gpt_name).context("failed to construct partition table")?; + build_fat32( + &mut fscommon::StreamSlice::new(file, range.start, range.end)?, + volume_label, + files, + ) + .context("failed to format volume") +} + +fn build_gpt(file: &mut (impl Read + Write + Seek), name: &str) -> anyhow::Result> { + let mut gpt = gptman::GPT::new_from(file, SECTOR_SIZE, Guid::new_random().into())?; + gptman::GPT::write_protective_mbr_into(file, SECTOR_SIZE)?; + gpt[1] = gptman::GPTPartitionEntry { + partition_type_guid: EFI_SYSTEM_PARTITION_GUID.into(), + unique_partition_guid: Guid::new_random().into(), + starting_lba: gpt.header.first_usable_lba, + ending_lba: gpt.header.last_usable_lba, + attribute_bits: 0, + partition_name: name.into(), + }; + gpt.write_into(file)?; + let start = gpt[1].starting_lba * SECTOR_SIZE; + // GPT LBAs are inclusive, so the sector count spans `end - start + 1`. + let bytes = (gpt[1].ending_lba - gpt[1].starting_lba + 1) * SECTOR_SIZE; + Ok(start..start + bytes) +} + +fn build_fat32( + file: &mut (impl Read + Write + Seek), + volume_label: &[u8; 11], + files: &[(&str, &[u8])], +) -> anyhow::Result<()> { + fatfs::format_volume( + &mut *file, + fatfs::FormatVolumeOptions::new() + .volume_label(*volume_label) + .fat_type(fatfs::FatType::Fat32), + ) + .context("failed to format volume")?; + let fs = fatfs::FileSystem::new(file, fatfs::FsOptions::new()).context("failed to open fs")?; + for (path, data) in files { + // Create parent directories (e.g. `EFI/BOOT`), since fatfs does not. + let mut dir = fs.root_dir(); + let mut components = path.split('/').peekable(); + let mut file_name = *path; + while let Some(component) = components.next() { + if components.peek().is_none() { + file_name = component; + break; + } + if component.is_empty() { + continue; + } + dir = match dir.open_dir(component) { + Ok(existing) => existing, + Err(_) => dir + .create_dir(component) + .context("failed to create directory")?, + }; + } + let mut dest = dir + .create_file(file_name) + .context("failed to create file")?; + dest.write_all(data).context("failed to write file")?; + dest.flush().context("failed to flush file")?; + } + fs.unmount().context("failed to unmount fs") +} diff --git a/opentmk/src/tests/hyperv/mod.rs b/opentmk/src/tests/hyperv/mod.rs index 93a7fa4f34..a52e75fa61 100644 --- a/opentmk/src/tests/hyperv/mod.rs +++ b/opentmk/src/tests/hyperv/mod.rs @@ -1,16 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -pub mod hv_error_vp_start; -#[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate -pub mod hv_memory_protect_read; -#[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate -pub mod hv_memory_protect_write; -pub mod hv_processor; -#[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate -pub mod hv_register_intercept; -#[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate -pub mod hv_tpm_read_cvm; -#[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate -pub mod hv_tpm_write_cvm; pub mod test_helpers; + +crate::opentmk_tests! { + ctx: crate::platform::hyperv::ctx::HvTestCtx, + tests: { + hv_error_vp_start, + hv_processor, + #[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate + hv_memory_protect_read, + #[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate + hv_memory_protect_write, + #[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate + hv_register_intercept, + #[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate + hv_tpm_read_cvm, + #[cfg(target_arch = "x86_64")] // xtask-fmt allow-target-arch sys-crate + hv_tpm_write_cvm, + }, +} diff --git a/opentmk/src/tests/mod.rs b/opentmk/src/tests/mod.rs index ec876317b6..5917749d6a 100644 --- a/opentmk/src/tests/mod.rs +++ b/opentmk/src/tests/mod.rs @@ -3,14 +3,85 @@ //! Test modules driving OpenTMK tests. -// only one test is run at a time so there is dead code in other tests -#![expect(dead_code)] -use crate::platform::hyperv::ctx::HvTestCtx; +use opentmk_protocol::OpenTmkConfig; + mod hyperv; -/// Runs all the tests. +/// Declares backend test modules and generates `run_named` to map names to `exec`. +/// Per-entry attributes gate the module and dispatch arm together. +#[macro_export] +macro_rules! opentmk_tests { + ( + ctx: $ctx:ty, + tests: { $( $(#[$meta:meta])* $module:ident ),* $(,)? } $(,)? + ) => { + $( $(#[$meta])* pub mod $module; )* + + /// Runs the named test. Returns `false` if no such test exists. + pub fn run_named(test: &str, ctx: &mut $ctx) -> bool { + match test { + $( + $(#[$meta])* + _ if test == stringify!($module) => { + $module::exec(ctx); + true + } + )* + _ => false, + } + } + }; +} + +/// Generates `dispatch` to select a backend, build context, and run the named test. +/// Returns `false` if unknown; entries map names to `|params: &serde_json::Value| -> Ctx`. +#[macro_export] +macro_rules! opentmk_backends { + ( $( $(#[$meta:meta])* $backend:ident => $build:expr ),* $(,)? ) => { + fn dispatch(backend: &str, test: &str, params: &::serde_json::Value) -> bool { + match backend { + $( + $(#[$meta])* + _ if backend == stringify!($backend) => { + let build = $build; + let mut ctx = build(params); + $backend::run_named(test, &mut ctx) + } + )* + _ => false, + } + } + }; +} + +crate::opentmk_backends! { + hyperv => |_params: &serde_json::Value| { + let mut ctx = crate::platform::hyperv::ctx::HvTestCtx::new(); + ctx.init(hvdef::Vtl::Vtl0).expect("failed to init on BSP"); + ctx + }, +} + +/// The embedded config region, patched in place by host tooling to select the +/// test to run. Layout and parsing live in [`opentmk_protocol`]. + +// SAFETY: `OPENTMK_CONFIG` is unique, so `no_mangle` cannot collide. +// `link_section = ".tmkcfg"` gives it a dedicated section with the patcher layout. +#[used] +#[unsafe(no_mangle)] +#[unsafe(link_section = ".tmkcfg")] +pub static OPENTMK_CONFIG: OpenTmkConfig = OpenTmkConfig::new(); + +/// Reads the embedded config and runs the selected backend/test. +/// Panics if config is invalid or names an unknown backend/test. pub fn run_test() { - let mut ctx = HvTestCtx::new(); - ctx.init(hvdef::Vtl::Vtl0).expect("failed to init on BSP"); - hyperv::hv_processor::exec(&mut ctx); + // `black_box` forces an opaque load so the optimizer cannot fold in the empty + // initializer; host patching happens after build and is invisible to the compiler. + let cfg = core::hint::black_box(&OPENTMK_CONFIG); + let Some(cfg) = cfg.parse() else { + panic!("TMK config missing or invalid: binary must be patched with a backend and test"); + }; + if !dispatch(&cfg.backend, &cfg.test, &cfg.params) { + panic!("unknown backend/test: '{}'/'{}'", cfg.backend, cfg.test); + } } diff --git a/opentmk_protocol/Cargo.toml b/opentmk_protocol/Cargo.toml new file mode 100644 index 0000000000..9bef15f9ad --- /dev/null +++ b/opentmk_protocol/Cargo.toml @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "opentmk_protocol" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["alloc"] } +thiserror.workspace = true +zerocopy = { workspace = true, features = ["derive"] } + +[lints] +workspace = true diff --git a/opentmk_protocol/src/lib.rs b/opentmk_protocol/src/lib.rs new file mode 100644 index 0000000000..6f1d747c57 --- /dev/null +++ b/opentmk_protocol/src/lib.rs @@ -0,0 +1,366 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared protocol for the OpenTMK build-time-patchable config region. +//! Keeps [`OpenTmkConfig`] layout and [`TestConfig`] JSON shared by guest and host. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +use alloc::string::String; +use serde::Deserialize; +use serde::Serialize; +use zerocopy::FromBytes; +use zerocopy::Immutable; +use zerocopy::IntoBytes; +use zerocopy::KnownLayout; +use zerocopy::little_endian::U32; + +/// Size of the embedded JSON config payload, in bytes. +pub const OPENTMK_CONFIG_JSON_SIZE: usize = 4096; + +/// Magic signature marking the start of the embedded [`OpenTmkConfig`] region. +/// Host patching validates it in [`OPENTMK_CONFIG_SECTION`]; non-zero keeps it out of `.bss`. +pub const OPENTMK_CONFIG_MAGIC: [u8; 16] = *b"OPENTMK_CFG_001\0"; + +/// Name of the PE section that holds the [`OpenTmkConfig`] region. +/// Must match `#[unsafe(link_section = ".tmkcfg")]` on `OPENTMK_CONFIG`. +pub const OPENTMK_CONFIG_SECTION: &str = ".tmkcfg"; + +/// Byte offset of `json_len` within the region, relative to the magic. +const OFFSET_JSON_LEN: usize = 16; +/// Byte offset of `json` within the region, relative to the magic. +const OFFSET_JSON: usize = 20; + +const _: () = { + assert!(OFFSET_JSON_LEN == core::mem::offset_of!(OpenTmkConfig, json_len)); + assert!(OFFSET_JSON == core::mem::offset_of!(OpenTmkConfig, json)); +}; + +/// Build-time-patchable config region embedded in the binary. +/// Fixed layout (`magic` | `json_len` | `json`) and alignment let host tooling patch raw bytes. +#[repr(C)] +#[derive(IntoBytes, FromBytes, Immutable, KnownLayout)] +pub struct OpenTmkConfig { + /// Locator signature. See [`OPENTMK_CONFIG_MAGIC`]. + pub magic: [u8; 16], + /// Number of valid bytes in `json` (little-endian, `<= OPENTMK_CONFIG_JSON_SIZE`). + pub json_len: U32, + /// JSON payload (UTF-8). Bytes beyond `json_len` are ignored. + pub json: [u8; OPENTMK_CONFIG_JSON_SIZE], +} + +impl OpenTmkConfig { + /// An unset config region (valid magic, empty payload) for host patching. + pub const fn new() -> Self { + Self { + magic: OPENTMK_CONFIG_MAGIC, + json_len: U32::ZERO, + json: [0; OPENTMK_CONFIG_JSON_SIZE], + } + } + + /// Parses the embedded JSON into a [`TestConfig`], or `None` if unset or + /// invalid. Never panics. + pub fn parse(&self) -> Option { + if self.magic != OPENTMK_CONFIG_MAGIC { + return None; + } + let len = self.json_len.get() as usize; + if len == 0 || len > OPENTMK_CONFIG_JSON_SIZE { + return None; + } + let s = core::str::from_utf8(&self.json[..len]).ok()?; + serde_json::from_str(s).ok() + } +} + +impl Default for OpenTmkConfig { + fn default() -> Self { + Self::new() + } +} + +/// Test configuration parsed from the embedded JSON payload. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TestConfig { + /// Optional schema version. + #[serde(default)] + pub version: u32, + /// Backend that owns the test, e.g. `"hyperv"`. + pub backend: String, + /// Test name within the backend, e.g. `"hv_processor"`. + pub test: String, + /// Arbitrary additional parameters (iteration counts, flags, etc.). + #[serde(default)] + pub params: serde_json::Value, +} + +/// Error returned by [`patch_opentmk_config`]. +#[derive(Debug, thiserror::Error)] +pub enum PatchError { + /// The JSON payload was not valid UTF-8. + #[error("config JSON is not valid UTF-8")] + InvalidUtf8, + /// The JSON payload did not parse as a [`TestConfig`]. + #[error("config JSON did not parse as a TestConfig")] + InvalidJson, + /// The JSON payload exceeds [`OPENTMK_CONFIG_JSON_SIZE`]. + #[error("config JSON is {len} bytes, exceeds maximum of {max} bytes")] + JsonTooLarge { + /// Length of the supplied JSON. + len: usize, + /// Maximum allowed length. + max: usize, + }, + /// The image was not a PE, or its config section did not contain the magic. + #[error("config magic signature not found in image")] + MagicNotFound, + /// The config region did not fit within its section after the magic. + #[error("image truncated: config region does not fit within its section (magic at offset {0})")] + Truncated(usize), +} + +/// Patches the embedded [`OpenTmkConfig`] region of `image` in place to carry `json`. +/// `image` must be raw PE `.efi`; `json` must be valid [`TestConfig`] <= [`OPENTMK_CONFIG_JSON_SIZE`]. +pub fn patch_opentmk_config(image: &mut [u8], json: &[u8]) -> Result<(), PatchError> { + let s = core::str::from_utf8(json).map_err(|_| PatchError::InvalidUtf8)?; + serde_json::from_str::(s).map_err(|_| PatchError::InvalidJson)?; + if json.len() > OPENTMK_CONFIG_JSON_SIZE { + return Err(PatchError::JsonTooLarge { + len: json.len(), + max: OPENTMK_CONFIG_JSON_SIZE, + }); + } + + // Locate the config region: find the magic anywhere within the `.tmkcfg` PE + // section, so we don't depend on it being the first thing in the section. + let (sec_off, sec_len) = config_section_range(image).ok_or(PatchError::MagicNotFound)?; + let section = &mut image[sec_off..sec_off + sec_len]; + let rel = section + .windows(OPENTMK_CONFIG_MAGIC.len()) + .position(|w| w == OPENTMK_CONFIG_MAGIC) + .ok_or(PatchError::MagicNotFound)?; + + // Take a typed view bounded to the section so a match near the section end + // can't overrun into following sections; a config that doesn't fit within + // the section is reported as `Truncated`. + let (region, _rest) = OpenTmkConfig::mut_from_prefix(&mut section[rel..]) + .map_err(|_| PatchError::Truncated(sec_off + rel))?; + region.json_len = U32::new(json.len() as u32); + region.json[..json.len()].copy_from_slice(json); + region.json[json.len()..].fill(0); + + Ok(()) +} + +/// Reads a little-endian `u16` at `off`, or `None` if out of bounds. +fn read_u16_le(image: &[u8], off: usize) -> Option { + let b = image.get(off..off.checked_add(2)?)?; + Some(u16::from_le_bytes([b[0], b[1]])) +} + +/// Reads a little-endian `u32` at `off`, or `None` if out of bounds. +fn read_u32_le(image: &[u8], off: usize) -> Option { + let b = image.get(off..off.checked_add(4)?)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) +} + +/// Locates the raw file range of [`OPENTMK_CONFIG_SECTION`] in a PE `.efi`, +/// or `None` if the image is not PE or lacks the section. +fn config_section_range(image: &[u8]) -> Option<(usize, usize)> { + // DOS header: the PE header offset is a `u32` at 0x3C. + let pe = read_u32_le(image, 0x3C)? as usize; + // PE signature. + if image.get(pe..pe.checked_add(4)?)? != b"PE\0\0" { + return None; + } + // The COFF file header follows the 4-byte signature; section headers follow + // the 20-byte COFF header and the variable-size optional header. + let coff = pe.checked_add(4)?; + let num_sections = read_u16_le(image, coff.checked_add(2)?)? as usize; + let opt_header_size = read_u16_le(image, coff.checked_add(16)?)? as usize; + let sections = coff.checked_add(20)?.checked_add(opt_header_size)?; + + // PE section names are 8 bytes, null-padded. + let mut want = [0u8; 8]; + let name = OPENTMK_CONFIG_SECTION.as_bytes(); + want.get_mut(..name.len())?.copy_from_slice(name); + + for i in 0..num_sections { + // Each section header is 40 bytes: name[8], then `SizeOfRawData` at + // offset 16 and `PointerToRawData` at offset 20. + let sh = sections.checked_add(i.checked_mul(40)?)?; + if image.get(sh..sh.checked_add(8)?)? == want { + let raw_size = read_u32_le(image, sh.checked_add(16)?)? as usize; + let raw_ptr = read_u32_le(image, sh.checked_add(20)?)? as usize; + if raw_ptr.checked_add(raw_size)? > image.len() { + return None; + } + return Some((raw_ptr, raw_size)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + + /// Builds a minimal PE image whose `.tmkcfg` section holds one config region, + /// preceded by `lead_pad` filler bytes within the section. + fn pe_with_region(lead_pad: usize) -> Vec { + // The config region: magic | json_len = 0 | json[JSON_SIZE]. + let mut region = Vec::new(); + region.extend_from_slice(&OPENTMK_CONFIG_MAGIC); + region.extend_from_slice(&0u32.to_le_bytes()); + region.extend_from_slice(&[0u8; OPENTMK_CONFIG_JSON_SIZE]); + + // Section data = leading filler (not the magic) followed by the region. + let mut section = vec![0xAAu8; lead_pad]; + section.extend_from_slice(®ion); + + let pe = 64usize; // e_lfanew + let opt_header_size = 0usize; // no optional header, for simplicity + let coff = pe + 4; + let sections = coff + 20 + opt_header_size; + let raw_ptr = sections + 40; // section data follows the one section header + + let mut image = vec![0u8; raw_ptr]; + image[0x3C..0x40].copy_from_slice(&(pe as u32).to_le_bytes()); + image[pe..pe + 4].copy_from_slice(b"PE\0\0"); + image[coff + 2..coff + 4].copy_from_slice(&1u16.to_le_bytes()); // num_sections + image[coff + 16..coff + 18].copy_from_slice(&(opt_header_size as u16).to_le_bytes()); + let sh = sections; + image[sh..sh + 8].copy_from_slice(b".tmkcfg\0"); + image[sh + 16..sh + 20].copy_from_slice(&(section.len() as u32).to_le_bytes()); + image[sh + 20..sh + 24].copy_from_slice(&(raw_ptr as u32).to_le_bytes()); + image.extend_from_slice(§ion); + image + } + + /// Builds a minimal PE image whose `.tmkcfg` section starts with the config. + fn image_with_region() -> Vec { + pe_with_region(0) + } + + /// Finds the config offset the way `patch_opentmk_config` does: the magic + /// within the `.tmkcfg` section. + fn config_base(image: &[u8]) -> usize { + let (sec_off, sec_len) = config_section_range(image).unwrap(); + let rel = image[sec_off..sec_off + sec_len] + .windows(OPENTMK_CONFIG_MAGIC.len()) + .position(|w| w == OPENTMK_CONFIG_MAGIC) + .unwrap(); + sec_off + rel + } + + #[test] + fn patch_round_trips() { + let mut image = image_with_region(); + let json = br#"{"backend":"hyperv","test":"hv_processor"}"#; + patch_opentmk_config(&mut image, json).unwrap(); + + let base = config_base(&image); + let json_len = u32::from_le_bytes( + image[base + OFFSET_JSON_LEN..base + OFFSET_JSON_LEN + 4] + .try_into() + .unwrap(), + ) as usize; + assert_eq!(json_len, json.len()); + let start = base + OFFSET_JSON; + let cfg: TestConfig = serde_json::from_slice(&image[start..start + json_len]).unwrap(); + assert_eq!(cfg.backend, "hyperv"); + assert_eq!(cfg.test, "hv_processor"); + } + + #[test] + fn config_not_at_section_start() { + // The config sits 128 bytes into `.tmkcfg`; patching must still find it. + let mut image = pe_with_region(128); + let json = br#"{"backend":"hyperv","test":"hv_processor"}"#; + patch_opentmk_config(&mut image, json).unwrap(); + + let base = config_base(&image); + let start = base + OFFSET_JSON; + let cfg: TestConfig = serde_json::from_slice(&image[start..start + json.len()]).unwrap(); + assert_eq!(cfg.backend, "hyperv"); + assert_eq!(cfg.test, "hv_processor"); + } + + #[test] + fn missing_magic_errors() { + let mut image = vec![0u8; 128]; + let json = br#"{"backend":"hyperv","test":"t"}"#; + assert!(matches!( + patch_opentmk_config(&mut image, json), + Err(PatchError::MagicNotFound) + )); + } + + #[test] + fn config_overrunning_section_is_truncated() { + // A `.tmkcfg` section that contains the magic but is too small to hold + // the full config, followed by ample trailing file bytes. Patching must + // report `Truncated` rather than overrun into the trailing bytes. + let pe = 64usize; + let coff = pe + 4; + let sections = coff + 20; + let raw_ptr = sections + 40; + let sec_len = OPENTMK_CONFIG_MAGIC.len() + 16; // magic + filler, far < region + + let mut image = vec![0u8; raw_ptr]; + image[0x3C..0x40].copy_from_slice(&(pe as u32).to_le_bytes()); + image[pe..pe + 4].copy_from_slice(b"PE\0\0"); + image[coff + 2..coff + 4].copy_from_slice(&1u16.to_le_bytes()); + image[coff + 16..coff + 18].copy_from_slice(&0u16.to_le_bytes()); + image[sections..sections + 8].copy_from_slice(b".tmkcfg\0"); + image[sections + 16..sections + 20].copy_from_slice(&(sec_len as u32).to_le_bytes()); + image[sections + 20..sections + 24].copy_from_slice(&(raw_ptr as u32).to_le_bytes()); + image.extend_from_slice(&OPENTMK_CONFIG_MAGIC); + image.extend_from_slice(&[0u8; 16]); + // Trailing bytes beyond the section, enough that an unbounded write would + // have found room for the whole region. + image.resize(raw_ptr + OPENTMK_CONFIG_JSON_SIZE + 64, 0); + + let json = br#"{"backend":"hyperv","test":"t"}"#; + assert!(matches!( + patch_opentmk_config(&mut image, json), + Err(PatchError::Truncated(_)) + )); + } + + #[test] + fn stray_magic_outside_section_ignored() { + // A copy of the magic outside `.tmkcfg` (as the compiler materializes in + // unoptimized builds, e.g. in `.rdata`) must not defeat patching. + let mut image = image_with_region(); + image.extend_from_slice(&OPENTMK_CONFIG_MAGIC); + let json = br#"{"backend":"hyperv","test":"hv_processor"}"#; + patch_opentmk_config(&mut image, json).unwrap(); + + let (base, _) = config_section_range(&image).unwrap(); + let json_len = u32::from_le_bytes( + image[base + OFFSET_JSON_LEN..base + OFFSET_JSON_LEN + 4] + .try_into() + .unwrap(), + ) as usize; + let start = base + OFFSET_JSON; + let cfg: TestConfig = serde_json::from_slice(&image[start..start + json_len]).unwrap(); + assert_eq!(cfg.backend, "hyperv"); + assert_eq!(cfg.test, "hv_processor"); + } + + #[test] + fn invalid_json_errors() { + let mut image = image_with_region(); + assert!(matches!( + patch_opentmk_config(&mut image, b"not json"), + Err(PatchError::InvalidJson) + )); + } +}