diff --git a/.gitignore b/.gitignore index b7145882..d0a4ef11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ + **/target/ /*/tools/ /*/image diff --git a/sev_verify/README.md b/sev_verify/README.md new file mode 100644 index 00000000..ff41f078 --- /dev/null +++ b/sev_verify/README.md @@ -0,0 +1,46 @@ +# sev_verify + +Host-side testing harness for SEV-SNP certification. Reads TOML manifests that declare which tests to run, imports per-test Python modules that define executable steps, and orchestrates execution across host and guest environments. + +## Usage + +```bash +# Run a specific certification level +python3 -m sev_verify /path/to/guest.efi -v 3.0 + +# Run multiple levels +python3 -m sev_verify /path/to/guest.efi -v 3.0 -v 3.1 + +# Run all certifications found in cert_tests/ +python3 -m sev_verify /path/to/guest.efi +``` + +## How it works + +1. Discover manifests at `cert_tests/*/manifest.toml`. Each manifest declares test entries (name, scope, module path). + +2. For each test, import its Python module from the same `cert_tests//` directory and call `steps()` to get the ordered list of `Step` objects. Steps specify a shell command, where it runs (host or guest), what constitutes success, and a timeout. + +3. Execute steps sequentially. Host steps run locally via subprocess. Guest steps are sent to the VM over a dedicated serial channel (`ttyS1`). For tests with `scope: guest` or `scope: mixed`, a QEMU SNP guest is launched before the first guest step and torn down after the last. + +4. Write results to `results/`. + +## Layout + +``` +sev_verify/ Harness package + cli.py CLI arg parsing + entry point + models.py Step, TestDefinition, CertificationDefinition + cert_tests/ Certification levels + common/ Shared test modules + snphost_ok.py Test modules + ... + cert_3_0/ Level 3.0 + manifest.toml What to run + ... +results/ Output (gitignored) +``` + +## Requirements + +Python 3.11+ (uses `tomllib` from stdlib). No external packages. diff --git a/sev_verify/__init__.py b/sev_verify/__init__.py new file mode 100644 index 00000000..4404d3fd --- /dev/null +++ b/sev_verify/__init__.py @@ -0,0 +1 @@ +"""sev-verify: SEV-SNP certification testing harness.""" diff --git a/sev_verify/__main__.py b/sev_verify/__main__.py new file mode 100644 index 00000000..89ae076b --- /dev/null +++ b/sev_verify/__main__.py @@ -0,0 +1,7 @@ +"""python3 -m sev_verify""" + +import sys + +from .cli import main + +sys.exit(main()) diff --git a/sev_verify/cert_tests/__init__.py b/sev_verify/cert_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sev_verify/cert_tests/cert_3_0/__init__.py b/sev_verify/cert_tests/cert_3_0/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sev_verify/cert_tests/cert_3_0/manifest.toml b/sev_verify/cert_tests/cert_3_0/manifest.toml new file mode 100644 index 00000000..5e5932f6 --- /dev/null +++ b/sev_verify/cert_tests/cert_3_0/manifest.toml @@ -0,0 +1,2 @@ +version = "3.0" +description = "SEV 3.0 Tests (AMD EPYC 7003+) - Current Level 3.0.0-0" diff --git a/sev_verify/cert_tests/common/__init__.py b/sev_verify/cert_tests/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sev_verify/cert_tests/common/snphost_ok.py b/sev_verify/cert_tests/common/snphost_ok.py new file mode 100644 index 00000000..4ce10316 --- /dev/null +++ b/sev_verify/cert_tests/common/snphost_ok.py @@ -0,0 +1,24 @@ +"""snphost-ok: Verify SNP is enabled and functional on the host.""" + +from sev_verify.models import Step + + +def steps() -> list[Step]: + return [ + Step( + name="snphost-ok", + type="required", + runs_on="host", + command="snphost ok", + expected_result="exit_code:0", + timeout=30, + ), + Step( + name="snphost-show-guests", + type="info", + runs_on="host", + command="snphost show guests", + expected_result="exit_code:0", + timeout=10, + ), + ] diff --git a/sev_verify/cli.py b/sev_verify/cli.py new file mode 100644 index 00000000..b2db7890 --- /dev/null +++ b/sev_verify/cli.py @@ -0,0 +1,105 @@ +"""CLI arg parsing and entry point for sev_verify.""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from pathlib import Path + +from .models import CertificationDefinition, TestDefinition + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="sev_verify", + description="SEV-SNP certification testing harness", + ) + parser.add_argument( + "path_to_guest", + help="Path to the guest image/UKI", + ) + parser.add_argument( + "--version", + "-v", + dest="versions", + action="append", + default=[], + help="Certification version(s) to run (e.g. 3.0). Repeatable. " + "If omitted, all cert_tests/*/manifest.toml are used.", + ) + return parser.parse_args(argv) + + +def load_manifest(toml_path: Path) -> CertificationDefinition: + """Load a TOML certification manifest into a CertificationDefinition.""" + with open(toml_path, "rb") as f: + data = tomllib.load(f) + + try: + tests = [TestDefinition(**t) for t in data.get("tests", [])] + return CertificationDefinition( + version=data["version"], + description=data["description"], + tests=tests, + ) + except (KeyError, TypeError) as exc: + raise ValueError(f"Invalid manifest {toml_path}: {exc}") from exc + + +def discover_manifests(cert_dir: Path, versions: list[str]) -> list[Path]: + """Find all manifest.toml files in cert_tests/ subdirectories.""" + if not cert_dir.is_dir(): + return [] + + if not versions: + return sorted(cert_dir.glob("*/manifest.toml")) + + manifest_paths = [] + for version in versions: + subfolder = "cert_" + version.replace(".", "_") + mpath = cert_dir / subfolder / "manifest.toml" + if not mpath.exists(): + print(f"Error: no manifest for version {version!r} " + f"(expected {mpath})", file=sys.stderr) + continue + manifest_paths.append(mpath) + + return manifest_paths + + +def print_certification(cert: CertificationDefinition) -> None: + """Print certification header.""" + header = f" Certification {cert.version} " + print(f"──{header}{'─' * (60 - len(header))}") + print(f" {cert.description}") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + guest_path = Path(args.path_to_guest) + + if not guest_path.exists(): + print(f"Error: guest path does not exist: {guest_path}", file=sys.stderr) + return 1 + + cert_dir = Path(__file__).resolve().parent / "cert_tests" + + manifest_paths = discover_manifests(cert_dir, args.versions) + + if not manifest_paths: + print( + "Error: no manifest.toml found in cert_tests/*/", + file=sys.stderr, + ) + return 1 + + print(f" Guest: {guest_path}") + print() + + for manifest_path in manifest_paths: + cert = load_manifest(manifest_path) + print_certification(cert) + print() + + return 0 diff --git a/sev_verify/models.py b/sev_verify/models.py new file mode 100644 index 00000000..77b60f06 --- /dev/null +++ b/sev_verify/models.py @@ -0,0 +1,80 @@ +"""Data model dataclasses for sev_verify.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +# ── Definition models (loaded from TOML manifests) ────────────── + + +@dataclass +class Step: + """A single executable step within a test.""" + + name: str + type: Literal["setup", "required", "info"] + runs_on: Literal["host", "guest"] + command: str + expected_result: str # e.g. "exit_code:0", "stdout_contains:PASS" + timeout: int = 60 + + +@dataclass +class TestDefinition: + """A test declared in the TOML manifest.""" + + name: str + module: str # dotted module path, e.g. "cert_tests.common.snphost_ok" + scope: Literal["host", "guest", "mixed"] + + @property + def requires_vm(self) -> bool: + return self.scope in ("guest", "mixed") + + +@dataclass +class CertificationDefinition: + """Top-level certification suite loaded from a TOML manifest.""" + + version: str + description: str + tests: list[TestDefinition] = field(default_factory=list) + + +# ── Runtime result models (populated during execution) ────────── + + +@dataclass +class StepResult: + """Result of executing a single Step.""" + + step: Step + result: Literal["pass", "fail", "error", "skip"] + exit_code: int | None = None + stdout: str | None = None + stderr: str | None = None + duration_ms: int | None = None + + +@dataclass +class TestResult: + """Result of executing a TestDefinition.""" + + test: TestDefinition + result: Literal["pass", "fail", "error"] + step_results: list[StepResult] = field(default_factory=list) + started_at: str | None = None + completed_at: str | None = None + + +@dataclass +class CertificationResult: + """Result of executing a CertificationDefinition.""" + + certification: CertificationDefinition + result: Literal["pass", "fail", "error"] + test_results: list[TestResult] = field(default_factory=list) + started_at: str | None = None + completed_at: str | None = None