use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Pass,
Warn,
Fail,
}
impl Verdict {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Pass => "PASS",
Self::Warn => "WARN",
Self::Fail => "FAIL",
}
}
}
#[derive(Debug, Clone)]
pub struct Check {
pub verdict: Verdict,
pub subject: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct Report {
pub checks: Vec<Check>,
}
impl Report {
#[must_use]
pub fn has_failures(&self) -> bool {
self.checks.iter().any(|c| c.verdict == Verdict::Fail)
}
#[must_use]
pub fn has_warnings(&self) -> bool {
self.checks.iter().any(|c| c.verdict == Verdict::Warn)
}
#[must_use]
pub fn next_action(&self) -> String {
if let Some(c) = self.checks.iter().find(|c| c.verdict == Verdict::Fail) {
return format!("FIX: {} — {}", c.subject, c.message);
}
if let Some(c) = self.checks.iter().find(|c| c.verdict == Verdict::Warn) {
return format!("CONSIDER: {} — {}", c.subject, c.message);
}
"ALL CHECKS PASS — harness is ready for any registered scenario".to_string()
}
#[must_use]
pub fn render(&self) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(self.checks.len() * 80);
let _ = writeln!(out, "{:<6} {:<30} MESSAGE", "STATUS", "SUBJECT");
for c in &self.checks {
let _ = writeln!(
out,
"{:<6} {:<30} {}",
c.verdict.label(),
c.subject,
c.message
);
}
let _ = writeln!(out);
let _ = writeln!(out, "NEXT ACTION: {}", self.next_action());
out
}
#[must_use]
pub fn render_json(&self) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(self.checks.len() * 200);
out.push_str("{\n");
out.push_str(" \"schema_version\": 1,\n");
out.push_str(" \"tool\": \"aegis-hwsim\",\n");
let _ = writeln!(
out,
" \"tool_version\": \"{}\",",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
" \"next_action\": \"{}\",",
crate::json::escape(&self.next_action())
);
let _ = writeln!(out, " \"has_failures\": {},", self.has_failures());
let _ = writeln!(out, " \"has_warnings\": {},", self.has_warnings());
out.push_str(" \"checks\": [\n");
let last = self.checks.len().saturating_sub(1);
for (i, c) in self.checks.iter().enumerate() {
let comma = if i == last { "" } else { "," };
out.push_str(" {\n");
let _ = writeln!(out, " \"verdict\": \"{}\",", c.verdict.label());
let _ = writeln!(
out,
" \"subject\": \"{}\",",
crate::json::escape(&c.subject)
);
let _ = writeln!(
out,
" \"message\": \"{}\"",
crate::json::escape(&c.message)
);
let _ = writeln!(out, " }}{comma}");
}
out.push_str(" ]\n");
out.push_str("}\n");
out
}
}
#[must_use]
pub fn run(firmware_root: &Path) -> Report {
let checks = vec![
check_binary(
"qemu-system-x86_64",
Verdict::Fail,
"Debian: apt install qemu-system-x86. Required for every scenario.",
),
check_binary(
"swtpm",
Verdict::Warn,
"Debian: apt install swtpm. Only no-TPM scenarios (qemu-boots-ovmf) \
can run without it; persona-driven TPM scenarios will Skip.",
),
check_binary(
"openssl",
Verdict::Warn,
"Debian: apt install openssl. Needed by `aegis-hwsim gen-test-keyring` \
to mint custom-PK + setup-mode test keyrings (E5 scenarios).",
),
check_binary(
"sbsign",
Verdict::Warn,
"Debian: apt install sbsigntool. Provides `sbsign`/`sbverify` for \
signing EFI binaries against the test keyring (E5 scenarios).",
),
check_binary(
"cert-to-efi-sig-list",
Verdict::Warn,
"Debian: apt install efitools. Converts X.509 certs into UEFI \
signature lists for OVMF VARS enrollment (E5 keyring generator).",
),
check_binary(
"virt-fw-vars",
Verdict::Warn,
"Debian: apt install python3-virt-firmware. Loads the generated \
PK/KEK/db into a working OVMF_VARS file (E5.1d enrollment step).",
),
check_firmware_file(
firmware_root,
"OVMF_CODE_4M.secboot.fd",
Verdict::Fail,
"Debian: apt install ovmf. Required for any Secure-Boot scenario.",
),
check_firmware_file(
firmware_root,
"OVMF_VARS_4M.ms.fd",
Verdict::Fail,
"Debian: apt install ovmf (provides the MS-enrolled VARS template).",
),
check_firmware_file(
firmware_root,
"OVMF_CODE_4M.fd",
Verdict::Warn,
"Optional: needed only by personas with ovmf_variant=disabled.",
),
check_firmware_file(
firmware_root,
"OVMF_VARS_4M.fd",
Verdict::Warn,
"Optional: needed by personas with ovmf_variant=setup_mode or =disabled.",
),
check_personas_dir(Path::new("personas")),
];
Report { checks }
}
fn check_binary(name: &str, severity_on_miss: Verdict, fix: &str) -> Check {
if let Some(path) = which_on_path(name) {
Check {
verdict: Verdict::Pass,
subject: name.to_string(),
message: format!("found at {}", path.display()),
}
} else {
Check {
verdict: severity_on_miss,
subject: name.to_string(),
message: format!("not on PATH. {fix}"),
}
}
}
fn check_firmware_file(root: &Path, filename: &str, severity_on_miss: Verdict, fix: &str) -> Check {
let path = root.join(filename);
if path.is_file() {
Check {
verdict: Verdict::Pass,
subject: filename.to_string(),
message: format!("found at {}", path.display()),
}
} else {
Check {
verdict: severity_on_miss,
subject: filename.to_string(),
message: format!("missing under {}. {fix}", root.display()),
}
}
}
fn check_personas_dir(personas_dir: &Path) -> Check {
if !personas_dir.is_dir() {
return Check {
verdict: Verdict::Fail,
subject: "personas/".into(),
message: format!(
"directory not found at {}. Run from the aegis-hwsim repo root.",
personas_dir.display()
),
};
}
let iter = match std::fs::read_dir(personas_dir) {
Ok(it) => it,
Err(e) => {
return Check {
verdict: Verdict::Fail,
subject: "personas/".into(),
message: format!("cannot read directory {}: {e}", personas_dir.display()),
};
}
};
let count = iter
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.is_some_and(|s| s == "yaml")
})
.count();
if count == 0 {
return Check {
verdict: Verdict::Fail,
subject: "personas/".into(),
message: format!(
"no .yaml files under {}. Persona library is empty.",
personas_dir.display()
),
};
}
Check {
verdict: Verdict::Pass,
subject: "personas/".into(),
message: format!("{count} persona file(s) present"),
}
}
fn which_on_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var("PATH").ok()?;
for dir in path.split(':') {
let candidate = PathBuf::from(dir).join(binary);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fake_firmware_root() -> (TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
for name in [
"OVMF_CODE_4M.secboot.fd",
"OVMF_CODE_4M.fd",
"OVMF_VARS_4M.ms.fd",
"OVMF_VARS_4M.fd",
] {
std::fs::write(root.join(name), b"fake").unwrap();
}
(tmp, root)
}
#[test]
fn next_action_picks_first_failure() {
let r = Report {
checks: vec![
Check {
verdict: Verdict::Pass,
subject: "a".into(),
message: "ok".into(),
},
Check {
verdict: Verdict::Fail,
subject: "missing-binary".into(),
message: "install via apt".into(),
},
Check {
verdict: Verdict::Warn,
subject: "should-not-be-picked".into(),
message: "warn".into(),
},
],
};
assert!(r.has_failures());
assert!(r.next_action().contains("missing-binary"));
assert!(r.next_action().starts_with("FIX:"));
}
#[test]
fn next_action_picks_warning_when_no_failures() {
let r = Report {
checks: vec![
Check {
verdict: Verdict::Pass,
subject: "a".into(),
message: "ok".into(),
},
Check {
verdict: Verdict::Warn,
subject: "swtpm".into(),
message: "install for TPM scenarios".into(),
},
],
};
assert!(!r.has_failures());
assert!(r.has_warnings());
let action = r.next_action();
assert!(action.starts_with("CONSIDER:"));
assert!(action.contains("swtpm"));
}
#[test]
fn next_action_celebrates_when_all_pass() {
let r = Report {
checks: vec![Check {
verdict: Verdict::Pass,
subject: "everything".into(),
message: "ok".into(),
}],
};
assert!(!r.has_failures());
assert!(!r.has_warnings());
assert!(r.next_action().starts_with("ALL CHECKS PASS"));
}
#[test]
fn check_firmware_file_returns_pass_when_present() {
let (_tmp, root) = fake_firmware_root();
let c = check_firmware_file(
&root,
"OVMF_CODE_4M.secboot.fd",
Verdict::Fail,
"install ovmf",
);
assert_eq!(c.verdict, Verdict::Pass);
assert!(c.message.contains("found at"));
}
#[test]
fn check_firmware_file_returns_severity_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let c = check_firmware_file(tmp.path(), "OVMF_CODE_4M.secboot.fd", Verdict::Fail, "fix");
assert_eq!(c.verdict, Verdict::Fail);
assert!(c.message.contains("missing"));
}
#[test]
fn check_binary_returns_pass_for_sh() {
let c = check_binary("sh", Verdict::Fail, "fix");
assert_eq!(c.verdict, Verdict::Pass);
}
#[test]
fn check_binary_returns_severity_for_missing() {
let c = check_binary("definitely-not-a-binary-xyz-doctor", Verdict::Warn, "fix");
assert_eq!(c.verdict, Verdict::Warn);
assert!(c.message.contains("not on PATH"));
}
#[test]
fn run_emits_e5_tool_probes_at_warn_severity() {
let (_tmp, root) = fake_firmware_root();
let r = run(&root);
let subjects: Vec<&str> = r.checks.iter().map(|c| c.subject.as_str()).collect();
for tool in ["openssl", "sbsign", "cert-to-efi-sig-list", "virt-fw-vars"] {
assert!(
subjects.contains(&tool),
"doctor must probe {tool} (got subjects: {subjects:?})"
);
let c = r.checks.iter().find(|c| c.subject == tool).unwrap();
assert_ne!(
c.verdict,
Verdict::Fail,
"{tool} must not be a hard Fail; got {:?} ({})",
c.verdict,
c.message
);
}
}
#[test]
fn check_personas_dir_returns_fail_for_missing_dir() {
let c = check_personas_dir(Path::new("/no/such/personas-dir-xyz"));
assert_eq!(c.verdict, Verdict::Fail);
}
#[test]
fn render_includes_status_subject_message_and_next_action() {
let (_tmp, root) = fake_firmware_root();
let r = run(&root);
let s = r.render();
assert!(s.contains("STATUS"));
assert!(s.contains("SUBJECT"));
assert!(s.contains("MESSAGE"));
assert!(s.contains("NEXT ACTION:"));
}
#[test]
fn render_json_emits_schema_version_envelope_and_checks_array() {
let (_tmp, root) = fake_firmware_root();
let r = run(&root);
let json = r.render_json();
assert!(json.contains("\"schema_version\": 1"));
assert!(json.contains("\"tool\": \"aegis-hwsim\""));
assert!(json.contains("\"tool_version\":"));
assert!(json.contains("\"next_action\":"));
assert!(json.contains("\"has_failures\":"));
assert!(json.contains("\"checks\": ["));
assert!(json.contains("\"verdict\":"));
assert!(json.contains("\"subject\":"));
}
#[test]
fn render_json_is_valid_json() {
let (_tmp, root) = fake_firmware_root();
let r = run(&root);
let json = r.render_json();
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("doctor --json output must parse");
assert_eq!(parsed["schema_version"], 1);
assert_eq!(parsed["tool"], "aegis-hwsim");
assert!(parsed["checks"].is_array());
}
#[test]
fn render_json_escapes_special_chars_in_messages() {
let r = Report {
checks: vec![Check {
verdict: Verdict::Warn,
subject: "test".into(),
message: "quote\" backslash\\ newline\n tab\t ctrl\x01 end".into(),
}],
};
let json = r.render_json();
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("escaped output must still parse");
let msg = parsed["checks"][0]["message"].as_str().unwrap();
assert!(msg.contains("quote\""));
assert!(msg.contains("backslash\\"));
assert!(msg.contains("newline\n"));
assert!(msg.contains("tab\t"));
assert!(msg.contains('\x01'));
}
}