use crate::persona::Persona;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScenarioResult {
Pass,
Fail {
reason: String,
},
Skip {
reason: String,
},
}
impl ScenarioResult {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Pass => "PASS",
Self::Fail { .. } => "FAIL",
Self::Skip { .. } => "SKIP",
}
}
#[must_use]
pub fn reason(&self) -> &str {
match self {
Self::Pass => "",
Self::Fail { reason } | Self::Skip { reason } => reason,
}
}
}
#[derive(Debug, Clone)]
pub struct ScenarioContext {
pub persona: Persona,
pub stick: PathBuf,
pub work_dir: PathBuf,
pub firmware_root: PathBuf,
}
#[derive(Debug, Error)]
pub enum ScenarioError {
#[error("scenario {scenario} cannot run against persona {persona}: {reason}")]
UnsupportedPersona {
scenario: &'static str,
persona: String,
reason: String,
},
#[error(transparent)]
Invocation(#[from] crate::qemu::InvocationError),
#[error(transparent)]
Swtpm(#[from] crate::swtpm::SwtpmError),
#[error(transparent)]
Serial(#[from] crate::serial::SerialError),
#[error("scenario I/O error: {kind}: {context}")]
Io {
kind: String,
context: String,
},
}
pub trait Scenario {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn run(&self, ctx: &ScenarioContext) -> Result<ScenarioResult, ScenarioError>;
}
pub struct Registry {
scenarios: Vec<Box<dyn Scenario + Send + Sync>>,
}
impl Default for Registry {
fn default() -> Self {
Self::default_set()
}
}
impl Registry {
#[must_use]
pub fn empty() -> Self {
Self {
scenarios: Vec::new(),
}
}
#[must_use]
pub fn default_set() -> Self {
let mut r = Self::empty();
r.register(Box::new(crate::scenarios::QemuBootsOvmf));
r.register(Box::new(crate::scenarios::SignedBootUbuntu));
r.register(Box::new(crate::scenarios::KexecRefusesUnsigned));
r.register(Box::new(crate::scenarios::MokEnrollAlpine));
r.register(Box::new(crate::scenarios::AttestationRoundtrip));
r
}
pub fn register(&mut self, s: Box<dyn Scenario + Send + Sync>) {
self.scenarios.push(s);
}
#[must_use]
pub fn find(&self, name: &str) -> Option<&(dyn Scenario + Send + Sync)> {
self.scenarios
.iter()
.find(|s| s.name() == name)
.map(std::convert::AsRef::as_ref)
}
pub fn iter(&self) -> impl Iterator<Item = (&'static str, &'static str)> + '_ {
self.scenarios.iter().map(|s| (s.name(), s.description()))
}
#[must_use]
pub fn len(&self) -> usize {
self.scenarios.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.scenarios.is_empty()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
struct NoopScenario {
name: &'static str,
result: ScenarioResult,
}
impl Scenario for NoopScenario {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"noop"
}
fn run(&self, _ctx: &ScenarioContext) -> Result<ScenarioResult, ScenarioError> {
Ok(self.result.clone())
}
}
fn fake_ctx() -> ScenarioContext {
ScenarioContext {
persona: serde_yaml_ng::from_str(
r#"
schema_version: 1
id: fake
vendor: QEMU
display_name: Fake
source:
kind: vendor_docs
ref_: fake
dmi:
sys_vendor: QEMU
product_name: Standard PC
bios_vendor: EDK II
bios_version: stable
bios_date: 01/01/2024
secure_boot:
ovmf_variant: ms_enrolled
tpm:
version: "2.0"
"#,
)
.unwrap(),
stick: PathBuf::from("/fake/stick.img"),
work_dir: PathBuf::from("/fake/work"),
firmware_root: PathBuf::from("/fake/fw"),
}
}
#[test]
fn scenario_result_labels_match_expected_strings() {
assert_eq!(ScenarioResult::Pass.label(), "PASS");
assert_eq!(ScenarioResult::Fail { reason: "x".into() }.label(), "FAIL");
assert_eq!(ScenarioResult::Skip { reason: "y".into() }.label(), "SKIP");
}
#[test]
fn scenario_result_reasons_extract_correctly() {
assert_eq!(ScenarioResult::Pass.reason(), "");
assert_eq!(
ScenarioResult::Fail {
reason: "hi".into()
}
.reason(),
"hi"
);
assert_eq!(
ScenarioResult::Skip {
reason: "missing dep".into()
}
.reason(),
"missing dep"
);
}
#[test]
fn registry_default_set_includes_shipped_scenarios() {
let r = Registry::default_set();
assert_eq!(r.len(), 5);
assert!(r.find("qemu-boots-ovmf").is_some());
assert!(r.find("signed-boot-ubuntu").is_some());
assert!(r.find("kexec-refuses-unsigned").is_some());
assert!(r.find("mok-enroll-alpine").is_some());
assert!(r.find("attestation-roundtrip").is_some());
}
#[test]
fn registry_find_returns_registered_scenario() {
let mut r = Registry::empty();
r.register(Box::new(NoopScenario {
name: "test-noop",
result: ScenarioResult::Pass,
}));
assert!(r.find("test-noop").is_some());
assert!(r.find("nonexistent").is_none());
}
#[test]
fn registry_find_returns_scenario_that_runs() {
let mut r = Registry::empty();
r.register(Box::new(NoopScenario {
name: "test-pass",
result: ScenarioResult::Pass,
}));
r.register(Box::new(NoopScenario {
name: "test-fail",
result: ScenarioResult::Fail {
reason: "pretend the stick refused to boot".into(),
},
}));
let pass = r.find("test-pass").unwrap();
assert_eq!(pass.run(&fake_ctx()).unwrap(), ScenarioResult::Pass);
let fail = r.find("test-fail").unwrap();
match fail.run(&fake_ctx()).unwrap() {
ScenarioResult::Fail { reason } => {
assert!(reason.contains("refused to boot"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn registry_iter_yields_name_description_pairs() {
let mut r = Registry::empty();
r.register(Box::new(NoopScenario {
name: "alpha",
result: ScenarioResult::Pass,
}));
r.register(Box::new(NoopScenario {
name: "beta",
result: ScenarioResult::Pass,
}));
let pairs: Vec<_> = r.iter().collect();
assert_eq!(pairs.len(), 2);
assert!(pairs.contains(&("alpha", "noop")));
assert!(pairs.contains(&("beta", "noop")));
}
}