use crate::persona::Persona;
use std::path::{Path, PathBuf};
use thiserror::Error;
const PLACEHOLDER_TOKEN: &str = "TEST_ONLY_NOT_FOR_PRODUCTION";
#[derive(Debug, Error)]
pub enum LoadError {
#[error("read {path:?}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parse {path:?}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("{path:?}: id '{yaml_id}' does not match filename stem '{filename_stem}'")]
IdMismatch {
path: PathBuf,
yaml_id: String,
filename_stem: String,
},
#[error("{path:?}: field {field:?} contains placeholder token; production personas must not carry test markers")]
Placeholder {
path: PathBuf,
field: String,
},
#[error("{path:?}: quirk tag '{tag}' does not match ^[a-z0-9][a-z0-9-]*[a-z0-9]$")]
QuirkTag {
path: PathBuf,
tag: String,
},
#[error("{path:?}: custom_keyring {keyring:?} is not under {root:?}")]
CustomKeyringOutsideRoot {
path: PathBuf,
keyring: PathBuf,
root: PathBuf,
},
#[error("firmware root {root:?} is missing or not canonicalizable: {source}")]
FirmwareRootMissing {
root: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path:?}: custom_keyring {keyring:?} (resolved: {resolved:?}) cannot be canonicalized: {source}")]
CustomKeyringMissing {
path: PathBuf,
keyring: PathBuf,
resolved: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"{path:?}: custom_keyring is only valid when ovmf_variant=custom_pk; \
got ovmf_variant={variant:?} with custom_keyring={keyring:?}"
)]
CustomKeyringWithWrongVariant {
path: PathBuf,
keyring: PathBuf,
variant: crate::persona::OvmfVariant,
},
}
#[derive(Debug, Clone)]
pub struct LoadOptions {
pub personas_dir: PathBuf,
pub firmware_root: PathBuf,
}
impl LoadOptions {
#[must_use]
pub fn default_at(repo_root: &Path) -> Self {
Self {
personas_dir: repo_root.join("personas"),
firmware_root: repo_root.join("firmware"),
}
}
}
pub fn load_all(opts: &LoadOptions) -> Result<Vec<Persona>, LoadError> {
let mut personas = Vec::new();
let read_dir = std::fs::read_dir(&opts.personas_dir).map_err(|source| LoadError::Read {
path: opts.personas_dir.clone(),
source,
})?;
let mut yaml_paths: Vec<PathBuf> = Vec::new();
for entry in read_dir {
let entry = entry.map_err(|source| LoadError::Read {
path: opts.personas_dir.clone(),
source,
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml") {
yaml_paths.push(path);
}
}
yaml_paths.sort();
for path in yaml_paths {
let persona = load_one(&path, opts)?;
personas.push(persona);
}
personas.sort_by(|a, b| a.id.cmp(&b.id));
Ok(personas)
}
fn load_one(path: &Path, opts: &LoadOptions) -> Result<Persona, LoadError> {
let body = std::fs::read_to_string(path).map_err(|source| LoadError::Read {
path: path.to_path_buf(),
source,
})?;
let persona: Persona = serde_yaml_ng::from_str(&body).map_err(|source| LoadError::Parse {
path: path.to_path_buf(),
source,
})?;
let filename_stem =
path.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| LoadError::Read {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
"persona path has no UTF-8 file stem",
),
})?;
if persona.id != filename_stem {
return Err(LoadError::IdMismatch {
path: path.to_path_buf(),
yaml_id: persona.id.clone(),
filename_stem: filename_stem.to_string(),
});
}
check_placeholder(&persona, path)?;
for quirk in &persona.quirks {
if !quirk_tag_is_valid(&quirk.tag) {
return Err(LoadError::QuirkTag {
path: path.to_path_buf(),
tag: quirk.tag.clone(),
});
}
}
if let Some(keyring) = &persona.secure_boot.custom_keyring {
check_custom_keyring(path, keyring, &opts.firmware_root)?;
}
if let Some(keyring) = &persona.secure_boot.custom_keyring {
if persona.secure_boot.ovmf_variant != crate::persona::OvmfVariant::CustomPk {
return Err(LoadError::CustomKeyringWithWrongVariant {
path: path.to_path_buf(),
keyring: keyring.clone(),
variant: persona.secure_boot.ovmf_variant,
});
}
}
Ok(persona)
}
fn check_placeholder(persona: &Persona, path: &Path) -> Result<(), LoadError> {
let fields: &[(&str, &str)] = &[
("id", &persona.id),
("vendor", &persona.vendor),
("display_name", &persona.display_name),
("source.ref_", &persona.source.ref_),
("dmi.sys_vendor", &persona.dmi.sys_vendor),
("dmi.product_name", &persona.dmi.product_name),
("dmi.bios_vendor", &persona.dmi.bios_vendor),
("dmi.bios_version", &persona.dmi.bios_version),
("dmi.bios_date", &persona.dmi.bios_date),
];
for (name, value) in fields {
if value.contains(PLACEHOLDER_TOKEN) {
return Err(LoadError::Placeholder {
path: path.to_path_buf(),
field: (*name).to_string(),
});
}
}
for (name, value) in [
(
"dmi.product_version",
persona.dmi.product_version.as_deref(),
),
("dmi.board_name", persona.dmi.board_name.as_deref()),
("source.captured_at", persona.source.captured_at.as_deref()),
("tpm.manufacturer", persona.tpm.manufacturer.as_deref()),
(
"tpm.firmware_version",
persona.tpm.firmware_version.as_deref(),
),
] {
if let Some(v) = value {
if v.contains(PLACEHOLDER_TOKEN) {
return Err(LoadError::Placeholder {
path: path.to_path_buf(),
field: name.to_string(),
});
}
}
}
for quirk in &persona.quirks {
if quirk.description.contains(PLACEHOLDER_TOKEN) {
return Err(LoadError::Placeholder {
path: path.to_path_buf(),
field: format!("quirks[{}].description", quirk.tag),
});
}
}
Ok(())
}
fn quirk_tag_is_valid(tag: &str) -> bool {
let bytes = tag.as_bytes();
if bytes.is_empty() {
return false;
}
let is_edge_char = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
if !is_edge_char(bytes[0]) || !is_edge_char(bytes[bytes.len() - 1]) {
return false;
}
if bytes.len() > 2 {
for &b in &bytes[1..bytes.len() - 1] {
if !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
return false;
}
}
}
true
}
fn check_custom_keyring(path: &Path, keyring: &Path, root: &Path) -> Result<(), LoadError> {
let canon_root = root
.canonicalize()
.map_err(|source| LoadError::FirmwareRootMissing {
root: root.to_path_buf(),
source,
})?;
let resolved = if keyring.is_absolute() {
keyring.to_path_buf()
} else {
canon_root.join(keyring)
};
let canon_keyring =
resolved
.canonicalize()
.map_err(|source| LoadError::CustomKeyringMissing {
path: path.to_path_buf(),
keyring: keyring.to_path_buf(),
resolved: resolved.clone(),
source,
})?;
if !canon_keyring.starts_with(&canon_root) {
return Err(LoadError::CustomKeyringOutsideRoot {
path: path.to_path_buf(),
keyring: keyring.to_path_buf(),
root: canon_root,
});
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use std::fs;
fn write(tmp: &Path, name: &str, body: &str) -> PathBuf {
let p = tmp.join(name);
fs::write(&p, body).unwrap();
p
}
fn minimal_yaml(id: &str) -> String {
format!(
r#"
schema_version: 1
id: {id}
vendor: QEMU
display_name: "Generic"
source:
kind: vendor_docs
ref_: "https://example.test/docs"
dmi:
sys_vendor: QEMU
product_name: "Standard PC"
bios_vendor: EDK II
bios_version: "edk2-stable"
bios_date: 01/01/2024
secure_boot:
ovmf_variant: ms_enrolled
tpm:
version: "2.0"
"#
)
}
fn tmp_opts() -> (tempfile::TempDir, LoadOptions) {
let tmp = tempfile::tempdir().unwrap();
let personas = tmp.path().join("personas");
let firmware = tmp.path().join("firmware");
fs::create_dir_all(&personas).unwrap();
fs::create_dir_all(&firmware).unwrap();
let opts = LoadOptions {
personas_dir: personas,
firmware_root: firmware,
};
(tmp, opts)
}
#[test]
fn load_all_accepts_minimal_valid_persona() {
let (_tmp, opts) = tmp_opts();
write(&opts.personas_dir, "ok.yaml", &minimal_yaml("ok"));
let loaded = load_all(&opts).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, "ok");
}
#[test]
fn load_all_returns_personas_sorted_by_id() {
let (_tmp, opts) = tmp_opts();
write(&opts.personas_dir, "z.yaml", &minimal_yaml("z"));
write(&opts.personas_dir, "a.yaml", &minimal_yaml("a"));
write(&opts.personas_dir, "m.yaml", &minimal_yaml("m"));
let loaded = load_all(&opts).unwrap();
let ids: Vec<_> = loaded.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, vec!["a", "m", "z"]);
}
#[test]
fn load_all_rejects_parse_error() {
let (_tmp, opts) = tmp_opts();
write(&opts.personas_dir, "bad.yaml", "not: [ valid");
let err = load_all(&opts).unwrap_err();
assert!(matches!(err, LoadError::Parse { .. }));
}
#[test]
fn load_all_rejects_id_mismatch() {
let (_tmp, opts) = tmp_opts();
write(&opts.personas_dir, "drift.yaml", &minimal_yaml("other"));
let err = load_all(&opts).unwrap_err();
match err {
LoadError::IdMismatch {
yaml_id,
filename_stem,
..
} => {
assert_eq!(yaml_id, "other");
assert_eq!(filename_stem, "drift");
}
other => panic!("expected IdMismatch, got {other:?}"),
}
}
#[test]
fn load_all_rejects_placeholder_token_in_display_name() {
let (_tmp, opts) = tmp_opts();
let body = minimal_yaml("tagged").replace(
"display_name: \"Generic\"",
"display_name: \"TEST_ONLY_NOT_FOR_PRODUCTION\"",
);
write(&opts.personas_dir, "tagged.yaml", &body);
let err = load_all(&opts).unwrap_err();
match err {
LoadError::Placeholder { field, .. } => assert_eq!(field, "display_name"),
other => panic!("expected Placeholder, got {other:?}"),
}
}
#[test]
fn quirk_tag_regex_accepts_valid_tags() {
assert!(quirk_tag_is_valid("fast-boot-default-on"));
assert!(quirk_tag_is_valid("a"));
assert!(quirk_tag_is_valid("a1"));
assert!(quirk_tag_is_valid("abc-123"));
assert!(quirk_tag_is_valid("0-x"));
}
#[test]
fn quirk_tag_regex_rejects_invalid_tags() {
assert!(!quirk_tag_is_valid(""));
assert!(!quirk_tag_is_valid("-leading"));
assert!(!quirk_tag_is_valid("trailing-"));
assert!(!quirk_tag_is_valid("UPPER"));
assert!(!quirk_tag_is_valid("has_underscore"));
assert!(!quirk_tag_is_valid("has space"));
}
#[test]
fn load_all_rejects_quirk_tag_with_uppercase() {
let (_tmp, opts) = tmp_opts();
let mut body = minimal_yaml("qt");
body.push_str("quirks:\n - tag: BAD_TAG\n description: nope\n");
write(&opts.personas_dir, "qt.yaml", &body);
let err = load_all(&opts).unwrap_err();
match err {
LoadError::QuirkTag { tag, .. } => assert_eq!(tag, "BAD_TAG"),
other => panic!("expected QuirkTag, got {other:?}"),
}
}
#[test]
fn custom_keyring_outside_root_is_rejected() {
let (_tmp, opts) = tmp_opts();
let escape_path = opts.personas_dir.parent().unwrap().join("escape-keyring");
fs::write(&escape_path, b"").unwrap();
let mut body = minimal_yaml("escape");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
&format!(
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: {}\n",
escape_path.display()
),
);
write(&opts.personas_dir, "escape.yaml", &body);
let err = load_all(&opts).unwrap_err();
assert!(
matches!(err, LoadError::CustomKeyringOutsideRoot { .. }),
"expected CustomKeyringOutsideRoot, got {err:?}"
);
}
#[test]
fn custom_keyring_inside_root_is_accepted() {
let (_tmp, opts) = tmp_opts();
let keyring = opts.firmware_root.join("test-keyring");
fs::write(&keyring, b"").unwrap();
let mut body = minimal_yaml("ok");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
&format!(
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: {}\n",
keyring.display()
),
);
write(&opts.personas_dir, "ok.yaml", &body);
let loaded = load_all(&opts).unwrap();
assert_eq!(loaded.len(), 1);
}
#[test]
fn custom_keyring_relative_path_resolves_against_firmware_root() {
let (_tmp, opts) = tmp_opts();
let sub = opts.firmware_root.join("sub");
fs::create_dir_all(&sub).unwrap();
let keyring_abs = sub.join("keyring.fd");
fs::write(&keyring_abs, b"placeholder").unwrap();
let mut body = minimal_yaml("relpath");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: sub/keyring.fd\n",
);
write(&opts.personas_dir, "relpath.yaml", &body);
let loaded = load_all(&opts).unwrap();
assert_eq!(loaded.len(), 1);
}
#[test]
fn firmware_root_missing_is_rejected_when_persona_uses_custom_keyring() {
let tmp = tempfile::tempdir().unwrap();
let personas = tmp.path().join("personas");
let firmware = tmp.path().join("firmware-does-not-exist");
fs::create_dir_all(&personas).unwrap();
let opts = LoadOptions {
personas_dir: personas,
firmware_root: firmware.clone(),
};
let mut body = minimal_yaml("rootless");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: keyring.fd\n",
);
write(&opts.personas_dir, "rootless.yaml", &body);
let err = load_all(&opts).unwrap_err();
match err {
LoadError::FirmwareRootMissing { root, .. } => {
assert_eq!(root, firmware);
}
other => panic!("expected FirmwareRootMissing, got {other:?}"),
}
}
#[test]
fn custom_keyring_with_non_custom_pk_variant_is_rejected() {
use crate::persona::OvmfVariant;
for (variant_yaml, expected) in [
("ms_enrolled", OvmfVariant::MsEnrolled),
("setup_mode", OvmfVariant::SetupMode),
("disabled", OvmfVariant::Disabled),
] {
let (_tmp, opts) = tmp_opts();
let keyring = opts.firmware_root.join("real-keyring.fd");
fs::write(&keyring, b"placeholder").unwrap();
let body = minimal_yaml("wrongvar").replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
&format!(
"secure_boot:\n ovmf_variant: {variant_yaml}\n custom_keyring: real-keyring.fd\n"
),
);
write(&opts.personas_dir, "wrongvar.yaml", &body);
let err = load_all(&opts).unwrap_err();
match err {
LoadError::CustomKeyringWithWrongVariant { variant, .. } => {
assert_eq!(
variant, expected,
"variant in error should match the persona's ovmf_variant"
);
}
other => panic!(
"expected CustomKeyringWithWrongVariant for {variant_yaml}, got {other:?}"
),
}
}
}
#[test]
fn custom_keyring_missing_file_is_rejected() {
let (_tmp, opts) = tmp_opts();
let mut body = minimal_yaml("missing");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: nope.fd\n",
);
write(&opts.personas_dir, "missing.yaml", &body);
let err = load_all(&opts).unwrap_err();
match err {
LoadError::CustomKeyringMissing {
keyring, resolved, ..
} => {
assert_eq!(keyring, PathBuf::from("nope.fd"));
assert_eq!(
resolved,
opts.firmware_root.canonicalize().unwrap().join("nope.fd")
);
}
other => panic!("expected CustomKeyringMissing, got {other:?}"),
}
}
#[test]
fn custom_keyring_relative_path_with_traversal_is_rejected() {
let (_tmp, opts) = tmp_opts();
let escape = opts.firmware_root.parent().unwrap().join("escape.fd");
fs::write(&escape, b"outside").unwrap();
let mut body = minimal_yaml("traversal");
body = body.replace(
"secure_boot:\n ovmf_variant: ms_enrolled\n",
"secure_boot:\n ovmf_variant: custom_pk\n custom_keyring: ../escape.fd\n",
);
write(&opts.personas_dir, "traversal.yaml", &body);
let err = load_all(&opts).unwrap_err();
assert!(
matches!(err, LoadError::CustomKeyringOutsideRoot { .. }),
"got {err:?}"
);
}
}