use std::sync::{Arc, Mutex};
use homecore::HomeCore;
use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
use crate::error::PluginError;
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
use crate::manifest::PluginManifest;
use crate::permissions::PermissionSet;
use crate::verify::{verify_module, PluginPolicy};
pub const MAX_WASM_MODULE_BYTES: usize = 16 * 1024 * 1024;
const MAX_SUBSCRIPTIONS: usize = 4096;
const MAX_LINEAR_MEMORY_BYTES: usize = 16 * 1024 * 1024;
const FUEL_PER_CALL: u64 = 10_000_000;
pub struct PluginStoreData {
pub hc: HomeCore,
pub subscriptions: Vec<String>,
pub permissions: PermissionSet,
limits: StoreLimits,
}
pub struct WasmtimeRuntime {
engine: Engine,
}
impl WasmtimeRuntime {
pub fn new() -> Result<Self, PluginError> {
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)
.map_err(|e| PluginError::RuntimeError(format!("Wasmtime engine: {e}")))?;
Ok(Self { engine })
}
pub fn load_wasm(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
) -> Result<WasmPlugin, PluginError> {
check_module_size(wasm_bytes)?;
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
}
pub fn load_plugin(
&self,
manifest: &PluginManifest,
wasm_bytes: &[u8],
hc: HomeCore,
policy: &PluginPolicy,
) -> Result<WasmPlugin, PluginError> {
check_module_size(wasm_bytes)?;
verify_module(manifest, wasm_bytes, policy)?;
let permissions = PermissionSet::from_manifest(manifest);
self.instantiate(wasm_bytes, hc, permissions)
}
fn instantiate(
&self,
wasm_bytes: &[u8],
hc: HomeCore,
permissions: PermissionSet,
) -> Result<WasmPlugin, PluginError> {
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
let mut linker: Linker<PluginStoreData> = Linker::new(&self.engine);
register_host_imports(&mut linker)?;
let store_data = PluginStoreData {
hc,
subscriptions: Vec::new(),
permissions,
limits: StoreLimitsBuilder::new()
.memory_size(MAX_LINEAR_MEMORY_BYTES)
.instances(1)
.memories(1)
.build(),
};
let mut store = Store::new(&self.engine, store_data);
store.limiter(|data| &mut data.limits);
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set instantiation fuel: {e}")))?;
let instance = linker
.instantiate(&mut store, &module)
.map_err(|e| PluginError::RuntimeError(format!("WASM instantiate: {e}")))?;
Ok(WasmPlugin {
inner: Arc::new(Mutex::new((store, instance))),
})
}
}
impl Default for WasmtimeRuntime {
fn default() -> Self {
Self::new().expect("default Wasmtime engine should not fail")
}
}
fn register_host_imports(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
register_hc_state_get(linker)?;
register_hc_state_set(linker)?;
register_hc_state_subscribe(linker)?;
register_hc_log(linker)?;
Ok(())
}
fn register_hc_state_get(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
linker
.func_wrap(
"env",
"hc_state_get",
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
key_ptr: i32,
key_len: i32,
out_ptr: i32,
out_cap: i32|
-> i32 {
if out_ptr < 0
|| out_cap < 0
|| out_cap as usize > MAX_ABI_BUFFER_BYTES
{
return -1;
}
let key: String = {
let mem = match caller.get_export("memory") {
Some(wasmtime::Extern::Memory(m)) => m,
_ => return -1,
};
match read_str(mem.data(&caller), key_ptr, key_len) {
Some(k) => k.to_owned(),
None => return -1,
}
};
let entity_id = match homecore::EntityId::parse(&key) {
Ok(id) => id,
Err(_) => return -1,
};
let json_bytes: Vec<u8> = {
let state_arc = match caller.data().hc.states().get(&entity_id) {
Some(s) => s,
None => return -1,
};
match serde_json::to_vec(&*state_arc) {
Ok(v) => v,
Err(_) => return -1,
}
};
if json_bytes.len() > out_cap as usize {
return -2;
}
let mem = match caller.get_export("memory") {
Some(wasmtime::Extern::Memory(m)) => m,
_ => return -1,
};
let Some(end) = (out_ptr as usize).checked_add(json_bytes.len()) else {
return -1;
};
let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) {
Some(s) => s,
None => return -1,
};
out.copy_from_slice(&json_bytes);
json_bytes.len() as i32
},
)
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_get: {e}")))?;
Ok(())
}
fn register_hc_state_set(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
linker
.func_wrap(
"env",
"hc_state_set",
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
eid_ptr: i32,
eid_len: i32,
state_ptr: i32,
state_len: i32,
attrs_ptr: i32,
attrs_len: i32|
-> i32 {
let (eid, new_state, attrs_str) = {
let mem = match caller.get_export("memory") {
Some(wasmtime::Extern::Memory(m)) => m,
_ => return -1,
};
let data = mem.data(&caller);
let eid = match read_str(data, eid_ptr, eid_len) {
Some(s) => s.to_owned(),
None => return -1,
};
let new_state = match read_str(data, state_ptr, state_len) {
Some(s) => s.to_owned(),
None => return -1,
};
let attrs_str = read_str(data, attrs_ptr, attrs_len)
.unwrap_or("{}")
.to_owned();
(eid, new_state, attrs_str)
};
let entity_id = match homecore::EntityId::parse(&eid) {
Ok(id) => id,
Err(_) => return -2,
};
if !caller.data().permissions.may_write(entity_id.as_str()) {
eprintln!(
"[PLUGIN WARN] denied hc_state_set on `{}` — not in plugin's declared \
homecore_permissions (P5 authority isolation)",
entity_id.as_str()
);
return -3;
}
let attrs: serde_json::Value =
serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({}));
caller
.data()
.hc
.states()
.set(entity_id, new_state, attrs, homecore::Context::new());
0
},
)
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_set: {e}")))?;
Ok(())
}
fn register_hc_state_subscribe(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
linker
.func_wrap(
"env",
"hc_state_subscribe",
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
eid_ptr: i32,
eid_len: i32|
-> i32 {
let eid: String = {
let mem = match caller.get_export("memory") {
Some(wasmtime::Extern::Memory(m)) => m,
_ => return -1,
};
match read_str(mem.data(&caller), eid_ptr, eid_len) {
Some(s) => s.to_owned(),
None => return -1,
}
};
if homecore::EntityId::parse(&eid).is_err() {
return -1;
}
if caller.data().subscriptions.len() >= MAX_SUBSCRIPTIONS {
return -2;
}
if !caller.data().subscriptions.contains(&eid) {
caller.data_mut().subscriptions.push(eid);
}
0
},
)
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_subscribe: {e}")))?;
Ok(())
}
fn register_hc_log(
linker: &mut Linker<PluginStoreData>,
) -> Result<(), PluginError> {
linker
.func_wrap(
"env",
"hc_log",
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
level: i32,
msg_ptr: i32,
msg_len: i32| {
let mem = match caller.get_export("memory") {
Some(wasmtime::Extern::Memory(m)) => m,
_ => return,
};
let msg = read_str(mem.data(&caller), msg_ptr, msg_len)
.unwrap_or("(invalid utf8)")
.to_owned();
let lvl = LogLevel::from_i32(level);
eprintln!("[PLUGIN {}] {}", lvl.as_str(), msg);
},
)
.map_err(|e| PluginError::RuntimeError(format!("register hc_log: {e}")))?;
Ok(())
}
#[derive(Clone)]
pub struct WasmPlugin {
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
}
impl WasmPlugin {
pub fn subscriptions(&self) -> Vec<String> {
self.inner
.lock()
.map(|g| g.0.data().subscriptions.clone())
.unwrap_or_default()
}
pub fn call_setup(&self, config_entry_json: &str) -> Result<i32, PluginError> {
let mut guard = self
.inner
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
call_export_str(store, instance, "plugin_setup", config_entry_json)
}
pub fn call_state_changed(
&self,
event: &StateChangedEventJson,
) -> Result<i32, PluginError> {
let json = serde_json::to_string(event)
.map_err(|e| PluginError::RuntimeError(format!("serialize event: {e}")))?;
let mut guard = self
.inner
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
call_export_str(store, instance, "plugin_handle_state_changed", &json)
}
pub fn call_teardown(&self) -> Result<i32, PluginError> {
let mut guard = self
.inner
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
let Some(func) = instance.get_func(&mut *store, "plugin_teardown") else {
return Ok(0);
};
let func = func.typed::<(), i32>(&*store).map_err(|e| {
PluginError::RuntimeError(format!("plugin_teardown has invalid signature: {e}"))
})?;
func.call(&mut *store, ())
.map_err(|e| PluginError::RuntimeError(format!("call plugin_teardown: {e}")))
}
}
fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> {
if ptr < 0 || len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
return None;
}
let ptr = ptr as usize;
let len = len as usize;
let end = ptr.checked_add(len)?;
let slice = mem.get(ptr..end)?;
std::str::from_utf8(slice).ok()
}
fn call_export_str(
store: &mut Store<PluginStoreData>,
instance: &wasmtime::Instance,
export_fn: &str,
payload: &str,
) -> Result<i32, PluginError> {
let payload_bytes = payload.as_bytes().to_vec(); if payload_bytes.len() > MAX_ABI_BUFFER_BYTES {
return Err(PluginError::ResourceLimit(format!(
"ABI payload is {} bytes; maximum is {}",
payload_bytes.len(),
MAX_ABI_BUFFER_BYTES
)));
}
let payload_len = payload_bytes.len() as i32;
let alloc = instance
.get_typed_func::<i32, i32>(&mut *store, "alloc")
.map_err(|e| PluginError::RuntimeError(format!("get alloc: {e}")))?;
let ptr = alloc
.call(&mut *store, payload_len)
.map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?;
if ptr < 0 {
return Err(PluginError::RuntimeError(
"guest alloc returned a negative pointer".into(),
));
}
{
let mem = instance
.get_memory(&mut *store, "memory")
.ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?;
let end = (ptr as usize)
.checked_add(payload_bytes.len())
.ok_or_else(|| PluginError::RuntimeError("guest allocation overflow".into()))?;
let guest_slice = mem
.data_mut(&mut *store)
.get_mut(ptr as usize..end)
.ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?;
guest_slice.copy_from_slice(&payload_bytes);
}
let func = instance
.get_typed_func::<(i32, i32), i32>(&mut *store, export_fn)
.map_err(|e| PluginError::RuntimeError(format!("get {export_fn}: {e}")))?;
let result = func
.call(&mut *store, (ptr, payload_len))
.map_err(|e| PluginError::RuntimeError(format!("call {export_fn}: {e}")))?;
let dealloc = instance
.get_typed_func::<(i32, i32), ()>(&mut *store, "dealloc")
.map_err(|e| PluginError::RuntimeError(format!("get dealloc: {e}")))?;
dealloc
.call(&mut *store, (ptr, payload_len))
.map_err(|e| PluginError::RuntimeError(format!("call dealloc: {e}")))?;
Ok(result)
}
fn check_module_size(wasm_bytes: &[u8]) -> Result<(), PluginError> {
if wasm_bytes.len() > MAX_WASM_MODULE_BYTES {
return Err(PluginError::ResourceLimit(format!(
"WASM module is {} bytes; maximum is {}",
wasm_bytes.len(),
MAX_WASM_MODULE_BYTES
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_WAT: &str = r#"
(module
;; Host imports
(import "env" "hc_state_get"
(func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set"
(func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe"
(func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log"
(func $hc_log (param i32 i32 i32)))
;; Linear memory: 1 page = 64 KiB
(memory (export "memory") 1)
;; Simple bump allocator state
(global $bump (mut i32) (i32.const 1024))
;; alloc(size) → ptr
(func (export "alloc") (param $size i32) (result i32)
(local $ptr i32)
(local.set $ptr (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get $size)))
(local.get $ptr)
)
;; dealloc(ptr, size) — no-op in bump allocator
(func (export "dealloc") (param i32 i32))
;; plugin_setup(ptr, len) → 0
(func (export "plugin_setup") (param i32 i32) (result i32)
(i32.const 0)
)
;; plugin_handle_state_changed(ptr, len) → 0
;; Calls hc_log with a fixed message so we can observe the import works.
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32)
;; log "ok" at INFO level — offset 0 in memory, write "ok" there first
(i32.store8 (i32.const 0) (i32.const 111)) ;; 'o'
(i32.store8 (i32.const 1) (i32.const 107)) ;; 'k'
(call $hc_log (i32.const 1) (i32.const 0) (i32.const 2))
(i32.const 0)
)
)
"#;
#[test]
fn wasmtime_runtime_compiles_and_instantiates_wat() {
let wasm_bytes = wat::parse_str(TEST_WAT).expect("WAT should parse");
let rt = WasmtimeRuntime::new().expect("engine should init");
let hc = HomeCore::new();
let plugin = rt.load_wasm(&wasm_bytes, hc).expect("should instantiate");
let r = plugin
.call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#)
.expect("setup should not error");
assert_eq!(r, 0, "plugin_setup should return 0");
}
#[test]
fn hc_state_set_round_trip_via_wat() {
const SET_WAT: &str = r#"
(module
(import "env" "hc_state_get"
(func $hc_state_get (param i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_set"
(func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "hc_state_subscribe"
(func $hc_state_subscribe (param i32 i32) (result i32)))
(import "env" "hc_log"
(func $hc_log (param i32 i32 i32)))
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 2048))
(func (export "alloc") (param $size i32) (result i32)
(local $ptr i32)
(local.set $ptr (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get $size)))
(local.get $ptr)
)
(func (export "dealloc") (param i32 i32))
;; Strings stored at known offsets in memory:
;; offset 0: "binary_sensor.test_alert" (24 bytes)
;; offset 64: "on" (2 bytes)
;; offset 128: "{}" (2 bytes)
(data (i32.const 0) "binary_sensor.test_alert")
(data (i32.const 64) "on")
(data (i32.const 128) "{}")
;; plugin_setup: call hc_state_set to write "on"
(func (export "plugin_setup") (param i32 i32) (result i32)
(call $hc_state_set
(i32.const 0) ;; eid_ptr
(i32.const 24) ;; eid_len = len("binary_sensor.test_alert")
(i32.const 64) ;; state_ptr
(i32.const 2) ;; state_len = len("on")
(i32.const 128) ;; attrs_ptr
(i32.const 2) ;; attrs_len = len("{}")
)
drop
(i32.const 0)
)
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32)
(i32.const 0)
)
)
"#;
let wasm_bytes = wat::parse_str(SET_WAT).expect("WAT should parse");
let rt = WasmtimeRuntime::new().expect("engine");
let hc = HomeCore::new();
let plugin = rt.load_wasm(&wasm_bytes, hc.clone()).expect("instantiate");
plugin.call_setup("{}").expect("setup");
let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap();
let state = hc.states().get(&eid).expect("state should exist");
assert_eq!(
state.state, "on",
"hc_state_set via host import should write 'on'"
);
}
}