Skip to main content

homecore_plugins/
manifest.rs

1//! Plugin manifest — superset of HA's `manifest.json`.
2//!
3//! See ADR-128 §3 for the full field list. Fields present in HA's schema
4//! are preserved verbatim. HOMECORE-specific fields are marked `[HOMECORE]`.
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::PluginError;
9
10/// Coarse-grained permission claim string (glob pattern).
11/// Example: `"state:write:sensor.*"`.
12pub type PermissionClaim = String;
13
14/// HA `iot_class` values (non-exhaustive — HA adds new classes over time).
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum IotClass {
18    LocalPush,
19    LocalPolling,
20    CloudPush,
21    CloudPolling,
22    AssumedState,
23    Calculated,
24    #[serde(other)]
25    Other,
26}
27
28/// HOMECORE integration type.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum IntegrationType {
32    Integration,
33    Helper,
34    Entity,
35    #[serde(other)]
36    Other,
37}
38
39/// Parsed and validated plugin manifest.
40///
41/// Serialises to/from HA-compatible `manifest.json`. HOMECORE-only fields
42/// are `Option<…>` so that a plain HA manifest is a valid (native-only)
43/// HOMECORE manifest.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct PluginManifest {
46    /// Unique integration domain identifier (e.g. `"mqtt"`).
47    pub domain: String,
48
49    /// Human-readable integration name.
50    pub name: String,
51
52    /// SemVer-ish version string (HA uses calendar-versioning, e.g. `"2025.1.0"`).
53    pub version: String,
54
55    /// Optional documentation URL.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub documentation: Option<String>,
58
59    /// HA `iot_class` — how the integration communicates with the device.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub iot_class: Option<IotClass>,
62
63    /// Whether this integration ships a UI config flow.
64    #[serde(default)]
65    pub config_flow: bool,
66
67    /// HOMECORE integration type (optional, defaults to Integration).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub integration_type: Option<IntegrationType>,
70
71    /// Intra-HOMECORE dependencies (other plugin domains this one requires).
72    #[serde(default)]
73    pub dependencies: Vec<String>,
74
75    /// External package requirements — kept for schema compat, ignored in HOMECORE
76    /// (WASM modules carry their own static deps, no pip).
77    #[serde(default)]
78    pub requirements: Vec<String>,
79
80    // ── [HOMECORE] fields ──────────────────────────────────────────────────
81
82    /// [HOMECORE] Relative path to the `.wasm` binary (absent for native plugins).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub wasm_module: Option<String>,
85
86    /// [HOMECORE] `sha256:<hex>` hash of the wasm binary.
87    ///
88    /// **(P4 — ENFORCED, ADR-162):** `verify::verify_module` computes the
89    /// SHA-256 of the real `.wasm` bytes on load and rejects the module if
90    /// it does not equal this hash (tamper detection). See [`crate::verify`].
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub wasm_module_hash: Option<String>,
93
94    /// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
95    ///
96    /// **(P4 — ENFORCED, ADR-162):** verified against `publisher_key` over
97    /// the SHA-256 module digest before instantiation. A bad/forged/absent
98    /// signature is rejected under the secure trust policy (the
99    /// `cog-ha-matter::witness_signing` Ed25519 pattern is reused).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub wasm_module_sig: Option<String>,
102
103    /// [HOMECORE] Ed25519 public key of the plugin publisher.
104    ///
105    /// **(P4 — ENFORCED, ADR-162):** used to verify `wasm_module_sig`, and
106    /// checked against the host's [`crate::verify::PluginPolicy`] trust
107    /// allowlist — an unknown publisher is rejected by the secure default.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub publisher_key: Option<String>,
110
111    /// [HOMECORE] Minimum HOMECORE version required by this plugin.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub min_homecore_version: Option<String>,
114
115    /// [HOMECORE] Subset of host functions the WASM module imports.
116    #[serde(default)]
117    pub host_imports_required: Vec<String>,
118
119    /// [HOMECORE] Coarse-grained permission claims (glob patterns).
120    ///
121    /// **(P5 — ENFORCED, ADR-162):** `state:write:<glob>` (or a bare entity
122    /// glob like `light.*`) grants are parsed into a
123    /// [`crate::permissions::PermissionSet` ] and consulted by the
124    /// `hc_state_set` host import. A plugin can no longer write an entity it
125    /// did not declare; a plugin with no write grants can write nothing.
126    #[serde(default)]
127    pub homecore_permissions: Vec<PermissionClaim>,
128
129    /// [HOMECORE] Seed app registry cog ID for distribution.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub cog_id: Option<String>,
132}
133
134impl PluginManifest {
135    /// Parse a `manifest.json` JSON string and validate required fields.
136    ///
137    /// Required fields: `domain`, `name`, `version`.
138    pub fn parse_json(s: &str) -> Result<Self, PluginError> {
139        let m: Self = serde_json::from_str(s)
140            .map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
141        m.validate()?;
142        Ok(m)
143    }
144
145    fn validate(&self) -> Result<(), PluginError> {
146        if self.domain.trim().is_empty() {
147            return Err(PluginError::InvalidManifest(
148                "manifest `domain` must not be empty".into(),
149            ));
150        }
151        if self.name.trim().is_empty() {
152            return Err(PluginError::InvalidManifest(
153                "manifest `name` must not be empty".into(),
154            ));
155        }
156        if self.version.trim().is_empty() {
157            return Err(PluginError::InvalidManifest(
158                "manifest `version` must not be empty".into(),
159            ));
160        }
161        Ok(())
162    }
163}