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}