Files
hms-mediaengine/packages/plugin_sdk/hms_plugin_sdk/manifest.py
T
HMS MediaEngine Agent 0922cc1d68 Phase 0: Repository-Initialisierung nach Bauplan v1.2
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis
- Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows),
  ADR-0003 IPC TCP+MessagePack v1
- Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet,
  hms_adaptive, hms_capabilities, hms_plugin_sdk
- Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5)
- Control Core: FastAPI REST + WebSocket (§36 Nr. 9)
- Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality-
  Varianten, HLSL/GLSL/GLES)
- Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV),
  Capability-Probe
- JSON-Schemas: IPC, Plugin, Projekt, Cluster
- 121 Unit-/Integrationstests grün, Ruff grün

Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz-
hardware gültig (§29.7, §33).
2026-09-11 00:36:59 +02:00

228 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Plugin-Manifest und Validierung (PLAN.md §14.214.6, §27.2).
Sicherheitsgrenzen:
- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP
- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen
- eindeutige Plugin-ID (reverse-dns), SemVer, api_version
- Shader-Dateien müssen je deklariertem Backend existieren
- max. 8 generische DMX-Slots je Effektinstanz (§14.7)
"""
from __future__ import annotations
import json
import zipfile
from enum import StrEnum
from pathlib import Path, PurePosixPath
from typing import Any
MANIFEST_SCHEMA_VERSION = 1
MAX_PLUGIN_FILES = 512
MAX_TOTAL_UNPACKED = 32 * 1024 * 1024
MAX_FILE_SIZE = 8 * 1024 * 1024
_ALLOWED_SUFFIXES = {
".json",
".hlsl",
".frag",
".vert",
".glsl",
".png",
".md",
".txt",
".toml",
".csv",
}
_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"}
class PluginKind(StrEnum):
SOURCE = "source"
GENERATOR = "generator"
FILTER = "filter"
TRANSITION = "transition"
MIXER = "mixer"
OUTPUT = "output"
CONTROL = "control"
AUTOMATION = "automation"
def _safe_relative(raw: str) -> PurePosixPath | None:
"""Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal)."""
if not raw:
return None
p = PurePosixPath(raw)
if p.is_absolute() or ".." in p.parts:
return None
return p
def _validate_parameters(params: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
seen: set[str] = set()
total_dmx_slots = 0
for param in params:
pid = param.get("id")
if not pid or not isinstance(pid, str):
errors.append("parameter without id")
continue
if pid in seen:
errors.append(f"duplicate parameter id: {pid}")
seen.add(pid)
ptype = param.get("type")
if ptype not in {"float", "int", "enum", "bool", "color"}:
errors.append(f"parameter {pid}: invalid type {ptype!r}")
if ptype == "float":
for key in ("minimum", "maximum", "default"):
if key not in param:
errors.append(f"parameter {pid}: missing {key}")
slots = param.get("dmx_slots", [])
if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots):
errors.append(f"parameter {pid}: dmx_slots must be int list")
slots = []
total_dmx_slots += len(slots)
if total_dmx_slots > 8:
errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)")
return errors
def _valid_plugin_id(pid: str) -> bool:
if ".." in pid or len(pid) < 5:
return False
parts = pid.split(".")
if len(parts) < 2:
return False
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-")
return all(c in allowed for c in pid)
def _valid_semver(version: str) -> bool:
parts = version.split(".")
if len(parts) != 3:
return False
try:
for p in parts:
int(p)
except ValueError:
return False
return True
def validate_manifest(
manifest: dict[str, Any], plugin_root: Path | None = None
) -> list[str]:
"""Validiert ein geparstes Manifest; leere Fehlerliste = gültig.
plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft.
"""
errors: list[str] = []
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}")
pid = manifest.get("id", "")
if not isinstance(pid, str) or not _valid_plugin_id(pid):
errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)")
for key in ("name", "version", "vendor"):
value = manifest.get(key)
if not isinstance(value, str) or not value:
errors.append(f"missing or empty {key}")
if not _valid_semver(manifest.get("version", "")):
errors.append("version must be semantic (X.Y.Z)")
if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}")
if manifest.get("kind") not in {k.value for k in PluginKind}:
errors.append(f"invalid kind: {manifest.get('kind')!r}")
entrypoints = manifest.get("entrypoints", {})
if not isinstance(entrypoints, dict) or not entrypoints:
errors.append("entrypoints required")
else:
supported = set(manifest.get("capabilities", {}).get("supported_backends", []))
unknown = supported - _ALLOWED_BACKENDS
if unknown:
errors.append(f"unsupported backends: {sorted(unknown)}")
for backend, entry in entrypoints.items():
if backend not in _ALLOWED_BACKENDS:
errors.append(f"entrypoint backend {backend!r} not allowed")
continue
if backend in supported:
passes = entry.get("passes", [])
if not passes:
errors.append(f"entrypoint {backend}: no passes")
for pas in passes:
shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment"
shader_rel = pas.get(shader_key)
if not shader_rel:
errors.append(f"entrypoint {backend}: pass without shader")
continue
sp = _safe_relative(shader_rel)
if sp is None:
errors.append(f"unsafe shader path: {shader_rel!r}")
continue
if plugin_root is not None and not (plugin_root / sp).is_file():
errors.append(f"missing shader file: {shader_rel}")
params = manifest.get("parameters", [])
if not isinstance(params, list):
errors.append("parameters must be a list")
else:
errors.extend(_validate_parameters(params))
if manifest.get("failure_mode") not in {"bypass", "hold", "black"}:
errors.append("failure_mode must be bypass|hold|black")
return errors
def validate_plugin_zip(zip_path: Path) -> list[str]:
"""Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2)."""
errors: list[str] = []
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_PLUGIN_FILES:
errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}")
total = 0
for info in zf.infolist():
if info.is_dir():
continue
total += info.file_size
if info.file_size > MAX_FILE_SIZE:
errors.append(f"file too large: {info.filename}")
if _safe_relative(info.filename) is None:
errors.append(f"unsafe path in zip: {info.filename!r}")
if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES:
errors.append(f"disallowed file type: {info.filename}")
if total > MAX_TOTAL_UNPACKED:
errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}")
manifest_name = next(
(n for n in names if n.endswith("plugin.json") and n.count("/") == 1),
None,
)
if manifest_name is None:
errors.append("plugin.json not found at package root")
else:
manifest = json.loads(zf.read(manifest_name))
errors.extend(validate_manifest(manifest))
except zipfile.BadZipFile:
errors.append("not a valid zip file")
except json.JSONDecodeError as exc:
errors.append(f"plugin.json invalid JSON: {exc}")
return errors
def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]:
"""Lädt und validiert plugin.json aus einem Plugin-Verzeichnis."""
manifest_path = plugin_dir / "plugin.json"
if not manifest_path.is_file():
return {}, ["plugin.json missing"]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return {}, [f"plugin.json invalid JSON: {exc}"]
return manifest, validate_manifest(manifest, plugin_root=plugin_dir)