630 lines
26 KiB
Rust
630 lines
26 KiB
Rust
//! Integration tests for ADR-128 P2 — Wasmtime runtime + example WASM plugin.
|
|
//!
|
|
//! ## Test strategy
|
|
//!
|
|
//! ### Primary path (compiled .wasm)
|
|
//!
|
|
//! Loads `homecore_plugin_example.wasm` from the known release output path
|
|
//! under the plugin-example's own target directory. If the binary is not
|
|
//! present (i.e., the example hasn't been built yet), the primary test is
|
|
//! skipped with a warning and the WAT-based fallback runs instead.
|
|
//!
|
|
//! To run the primary path:
|
|
//!
|
|
//! ```sh
|
|
//! # From v2/crates/homecore-plugin-example:
|
|
//! /c/Users/ruv/.cargo/bin/cargo build --target wasm32-unknown-unknown --release
|
|
//! # Then from v2/:
|
|
//! cargo test -p homecore-plugins --features wasmtime
|
|
//! ```
|
|
//!
|
|
//! ### Fallback path (inline WAT)
|
|
//!
|
|
//! Always runs. Uses `wat::parse_str` to compile a hand-written WAT module
|
|
//! that implements the same temperature-threshold logic as the Rust plugin.
|
|
//! This proves the Wasmtime linker works and all 4 host imports are wired
|
|
//! correctly even without a pre-built `.wasm` binary.
|
|
|
|
#[cfg(feature = "wasmtime")]
|
|
mod wasmtime_tests {
|
|
use homecore::HomeCore;
|
|
use homecore_plugins::wasmtime_runtime::WasmtimeRuntime;
|
|
use homecore_plugins::StateChangedEventJson;
|
|
|
|
// ── Path to compiled example binary ────────────────────────────────────
|
|
|
|
/// Path to the pre-compiled example WASM relative to the workspace root.
|
|
///
|
|
/// The example crate has its own isolated Cargo workspace so its target
|
|
/// directory lives under the crate itself, not the v2/ workspace target.
|
|
const EXAMPLE_WASM_PATH: &str = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../crates/homecore-plugin-example/target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm"
|
|
);
|
|
|
|
// ── WAT fallback (always runnable) ─────────────────────────────────────
|
|
|
|
/// WAT module implementing the same temperature-threshold logic as
|
|
/// `homecore-plugin-example`. Used when the compiled .wasm is unavailable.
|
|
///
|
|
/// Behaviour:
|
|
/// - `plugin_setup` → subscribes to `sensor.test_temp` via `hc_state_subscribe`
|
|
/// - `plugin_handle_state_changed` → parses the `new_state` field from
|
|
/// the event JSON and calls `hc_state_set` to write `binary_sensor.test_alert`
|
|
///
|
|
/// This WAT version uses a simplified string scan rather than full JSON
|
|
/// parsing, which is sufficient for the test payloads.
|
|
const THRESHOLD_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") 2)
|
|
(global $bump (mut i32) (i32.const 4096))
|
|
|
|
;; Static data at known offsets:
|
|
;; 0: "sensor.test_temp" (16 bytes)
|
|
;; 64: "binary_sensor.test_alert" (24 bytes)
|
|
;; 128: "on" (2 bytes)
|
|
;; 192: "off" (3 bytes)
|
|
;; 256: "{}" (2 bytes)
|
|
(data (i32.const 0) "sensor.test_temp")
|
|
(data (i32.const 64) "binary_sensor.test_alert")
|
|
(data (i32.const 128) "on")
|
|
(data (i32.const 192) "off")
|
|
(data (i32.const 256) "{}")
|
|
|
|
(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))
|
|
|
|
;; plugin_setup: subscribe to sensor.test_temp
|
|
(func (export "plugin_setup") (param i32 i32) (result i32)
|
|
(call $hc_state_subscribe (i32.const 0) (i32.const 16))
|
|
drop
|
|
(i32.const 0)
|
|
)
|
|
|
|
;; plugin_handle_state_changed(ptr, len) → i32
|
|
;;
|
|
;; The host passes a JSON string. We scan for "\"new_state\":\"" and read
|
|
;; one or two ASCII digit bytes to determine if temp > 25 or < 20.
|
|
;; The test values are "26" (above 25) and "19" (below 20), so we read
|
|
;; the first two digits after the marker and compare numerically.
|
|
;;
|
|
;; Scan strategy: find byte sequence for "new_state":"
|
|
;; Then read the decimal integer that follows until '"'.
|
|
;;
|
|
;; We implement a simple integer parser inline in WAT.
|
|
(func (export "plugin_handle_state_changed") (param $ptr i32) (param $len i32) (result i32)
|
|
(local $i i32) ;; scan index into the event buffer
|
|
(local $end i32) ;; ptr + len
|
|
(local $num i32) ;; parsed integer temperature
|
|
(local $neg i32) ;; 1 if negative
|
|
(local $ch i32) ;; current character
|
|
(local $found i32) ;; 1 if marker found
|
|
|
|
;; We look for the 13-byte sequence: "new_state":"
|
|
;; Simplified: scan for byte 'n','e','w' consecutively to find the field.
|
|
;; Full marker: "new_state":" (len=13 including both quotes and colon)
|
|
;; Bytes: 22 6e 65 77 5f 73 74 61 74 65 22 3a 22
|
|
;; " n e w _ s t a t e " : "
|
|
|
|
(local.set $end (i32.add (local.get $ptr) (local.get $len)))
|
|
(local.set $i (local.get $ptr))
|
|
(local.set $found (i32.const 0))
|
|
|
|
;; Scan for '"new_state":"'
|
|
(block $done
|
|
(loop $scan
|
|
;; Bounds check
|
|
(br_if $done (i32.ge_u (i32.add (local.get $i) (i32.const 13)) (local.get $end)))
|
|
;; Check 13-byte marker
|
|
(if
|
|
(i32.and
|
|
(i32.and
|
|
(i32.eq (i32.load8_u (local.get $i)) (i32.const 0x22)) ;; "
|
|
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 0x6e)) ;; n
|
|
)
|
|
(i32.and
|
|
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 11))) (i32.const 0x3a)) ;; :
|
|
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 12))) (i32.const 0x22)) ;; "
|
|
)
|
|
)
|
|
(then
|
|
;; Advance past marker to the value start
|
|
(local.set $i (i32.add (local.get $i) (i32.const 13)))
|
|
(local.set $found (i32.const 1))
|
|
(br $done)
|
|
)
|
|
)
|
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
(br $scan)
|
|
)
|
|
)
|
|
|
|
;; If not found or null value, return 0 (no-op).
|
|
(if (i32.eqz (local.get $found)) (then (return (i32.const 0))))
|
|
|
|
;; Parse integer from current position.
|
|
(local.set $num (i32.const 0))
|
|
(local.set $neg (i32.const 0))
|
|
|
|
;; Check for minus sign.
|
|
(if (i32.lt_u (local.get $i) (local.get $end))
|
|
(then
|
|
(local.set $ch (i32.load8_u (local.get $i)))
|
|
(if (i32.eq (local.get $ch) (i32.const 0x2d)) ;; '-'
|
|
(then
|
|
(local.set $neg (i32.const 1))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
;; Parse digits.
|
|
(block $numDone
|
|
(loop $digits
|
|
(br_if $numDone (i32.ge_u (local.get $i) (local.get $end)))
|
|
(local.set $ch (i32.load8_u (local.get $i)))
|
|
;; Stop at non-digit or dot (we ignore decimals for integer comparison)
|
|
(br_if $numDone (i32.lt_u (local.get $ch) (i32.const 0x30))) ;; < '0'
|
|
(br_if $numDone (i32.gt_u (local.get $ch) (i32.const 0x39))) ;; > '9'
|
|
(local.set $num
|
|
(i32.add
|
|
(i32.mul (local.get $num) (i32.const 10))
|
|
(i32.sub (local.get $ch) (i32.const 0x30))
|
|
)
|
|
)
|
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
(br $digits)
|
|
)
|
|
)
|
|
|
|
;; Apply negative sign.
|
|
(if (local.get $neg)
|
|
(then (local.set $num (i32.sub (i32.const 0) (local.get $num))))
|
|
)
|
|
|
|
;; Apply threshold: > 25 → set alert ON; < 20 → set alert OFF.
|
|
(if (i32.gt_s (local.get $num) (i32.const 25))
|
|
(then
|
|
(call $hc_state_set
|
|
(i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert"
|
|
(i32.const 128) (i32.const 2) ;; state: "on"
|
|
(i32.const 256) (i32.const 2) ;; attrs: "{}"
|
|
)
|
|
drop
|
|
)
|
|
)
|
|
(if (i32.lt_s (local.get $num) (i32.const 20))
|
|
(then
|
|
(call $hc_state_set
|
|
(i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert"
|
|
(i32.const 192) (i32.const 3) ;; state: "off"
|
|
(i32.const 256) (i32.const 2) ;; attrs: "{}"
|
|
)
|
|
drop
|
|
)
|
|
)
|
|
(i32.const 0)
|
|
)
|
|
)
|
|
"#;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
fn build_rt_and_hc() -> (WasmtimeRuntime, HomeCore) {
|
|
(
|
|
WasmtimeRuntime::new().expect("WasmtimeRuntime::new"),
|
|
HomeCore::new(),
|
|
)
|
|
}
|
|
|
|
fn state_changed_event(entity_id: &str, new_state: &str) -> StateChangedEventJson {
|
|
StateChangedEventJson::state_changed(
|
|
entity_id,
|
|
Some(new_state),
|
|
serde_json::json!({}),
|
|
)
|
|
}
|
|
|
|
fn assert_alert_state(hc: &HomeCore, expected: &str) {
|
|
let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap();
|
|
let state = hc
|
|
.states()
|
|
.get(&eid)
|
|
.unwrap_or_else(|| panic!("binary_sensor.test_alert not found in state machine"));
|
|
assert_eq!(
|
|
state.state, expected,
|
|
"binary_sensor.test_alert should be '{expected}' but was '{}'",
|
|
state.state
|
|
);
|
|
}
|
|
|
|
// ── Primary test: compiled .wasm binary ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn wasm_plugin_temp_threshold_compiled_binary() {
|
|
let wasm_path = std::path::Path::new(EXAMPLE_WASM_PATH);
|
|
if !wasm_path.exists() {
|
|
eprintln!(
|
|
"[SKIP] {EXAMPLE_WASM_PATH} not found. \
|
|
Build the example first:\n \
|
|
cd v2/crates/homecore-plugin-example && \
|
|
cargo build --target wasm32-unknown-unknown --release"
|
|
);
|
|
return; // skip — binary not built yet
|
|
}
|
|
|
|
let wasm_bytes = std::fs::read(wasm_path)
|
|
.expect("failed to read homecore_plugin_example.wasm");
|
|
|
|
let (rt, hc) = build_rt_and_hc();
|
|
let plugin = rt
|
|
.load_wasm(&wasm_bytes, hc.clone())
|
|
.expect("load_wasm should succeed");
|
|
|
|
// Call plugin_setup — should subscribe to sensor.test_temp.
|
|
let setup_result = plugin
|
|
.call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#)
|
|
.expect("plugin_setup should not trap");
|
|
assert_eq!(setup_result, 0, "plugin_setup should return 0");
|
|
|
|
// Verify subscription was recorded.
|
|
assert!(
|
|
plugin.subscriptions().contains(&"sensor.test_temp".to_owned()),
|
|
"plugin should have subscribed to sensor.test_temp"
|
|
);
|
|
|
|
// ── Scenario 1: temp = 26.0 → alert ON ──────────────────────────────
|
|
let event_hot = state_changed_event("sensor.test_temp", "26.0");
|
|
plugin
|
|
.call_state_changed(&event_hot)
|
|
.expect("state_changed should not trap");
|
|
assert_alert_state(&hc, "on");
|
|
|
|
// ── Scenario 2: temp = 19.0 → alert OFF ─────────────────────────────
|
|
let event_cold = state_changed_event("sensor.test_temp", "19.0");
|
|
plugin
|
|
.call_state_changed(&event_cold)
|
|
.expect("state_changed should not trap");
|
|
assert_alert_state(&hc, "off");
|
|
}
|
|
|
|
// ── Fallback test: inline WAT (always runs) ───────────────────────────────
|
|
|
|
#[test]
|
|
fn wasm_plugin_temp_threshold_wat_fallback() {
|
|
let wasm_bytes = wat::parse_str(THRESHOLD_WAT).expect("WAT should parse");
|
|
|
|
let (rt, hc) = build_rt_and_hc();
|
|
let plugin = rt
|
|
.load_wasm(&wasm_bytes, hc.clone())
|
|
.expect("load_wasm should succeed for WAT");
|
|
|
|
// plugin_setup → subscribes
|
|
let r = plugin.call_setup("{}").expect("setup");
|
|
assert_eq!(r, 0);
|
|
|
|
// ── Scenario 1: temp = 26 → alert ON ───────────────────────────────
|
|
let hot_event = StateChangedEventJson::state_changed(
|
|
"sensor.test_temp",
|
|
Some("26"),
|
|
serde_json::json!({}),
|
|
);
|
|
plugin
|
|
.call_state_changed(&hot_event)
|
|
.expect("state_changed should not trap");
|
|
assert_alert_state(&hc, "on");
|
|
|
|
// ── Scenario 2: temp = 19 → alert OFF ──────────────────────────────
|
|
let cold_event = StateChangedEventJson::state_changed(
|
|
"sensor.test_temp",
|
|
Some("19"),
|
|
serde_json::json!({}),
|
|
);
|
|
plugin
|
|
.call_state_changed(&cold_event)
|
|
.expect("state_changed should not trap");
|
|
assert_alert_state(&hc, "off");
|
|
}
|
|
|
|
// ── Linker smoke test ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn wasmtime_linker_wires_all_four_host_imports() {
|
|
// A minimal WAT that calls all 4 host imports once and returns 0.
|
|
const SMOKE_WAT: &str = r#"
|
|
(module
|
|
(import "env" "hc_state_get" (func (param i32 i32 i32 i32) (result i32)))
|
|
(import "env" "hc_state_set" (func (param i32 i32 i32 i32 i32 i32) (result i32)))
|
|
(import "env" "hc_state_subscribe" (func (param i32 i32) (result i32)))
|
|
(import "env" "hc_log" (func (param i32 i32 i32)))
|
|
(memory (export "memory") 1)
|
|
(global $bump (mut i32) (i32.const 512))
|
|
(func (export "alloc") (param i32) (result i32)
|
|
(local $p i32)
|
|
(local.set $p (global.get $bump))
|
|
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
|
(local.get $p))
|
|
(func (export "dealloc") (param i32 i32))
|
|
(func (export "plugin_setup") (param i32 i32) (result i32) (i32.const 0))
|
|
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
|
)
|
|
"#;
|
|
let wasm_bytes = wat::parse_str(SMOKE_WAT).expect("WAT");
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
let hc = HomeCore::new();
|
|
let plugin = rt.load_wasm(&wasm_bytes, hc).expect("instantiate");
|
|
let r = plugin.call_setup("{}").expect("setup");
|
|
assert_eq!(r, 0);
|
|
}
|
|
|
|
// ── ADR-162 P4: signature/integrity verification ────────────────────────
|
|
//
|
|
// Each of these FAILS on the pre-ADR-162 code, which had no
|
|
// `load_plugin` / `verify_module` at all — the manifest hash/sig/key
|
|
// were parsed and discarded. They drive the real verification gate.
|
|
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
use homecore_plugins::manifest::PluginManifest;
|
|
use homecore_plugins::verify::{encode_sha256, encode_signature, encode_verifying_key};
|
|
use homecore_plugins::PluginPolicy;
|
|
|
|
/// Deterministic publisher key (fixed seed — never use in production;
|
|
/// mirrors the cog-ha-matter witness_signing test-key convention).
|
|
fn publisher_key() -> SigningKey {
|
|
SigningKey::from_bytes(b"hc-plugins-integration-pub-seed-")
|
|
}
|
|
|
|
fn untrusted_key() -> SigningKey {
|
|
SigningKey::from_bytes(b"hc-plugins-integration-evil-seed")
|
|
}
|
|
|
|
/// A minimal valid module that writes `light.kitchen` on setup, plus a
|
|
/// `light.*` permission grant. Returns the WAT source.
|
|
const WRITE_LIGHT_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 512))
|
|
(data (i32.const 0) "light.kitchen")
|
|
(data (i32.const 64) "on")
|
|
(data (i32.const 128) "{}")
|
|
(func (export "alloc") (param i32) (result i32)
|
|
(local $p i32)
|
|
(local.set $p (global.get $bump))
|
|
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
|
(local.get $p))
|
|
(func (export "dealloc") (param i32 i32))
|
|
(func (export "plugin_setup") (param i32 i32) (result i32)
|
|
(call $hc_state_set
|
|
(i32.const 0) (i32.const 13) ;; "light.kitchen"
|
|
(i32.const 64) (i32.const 2) ;; "on"
|
|
(i32.const 128) (i32.const 2)) ;; "{}"
|
|
drop
|
|
(i32.const 0))
|
|
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
|
)
|
|
"#;
|
|
|
|
/// Build a manifest signed by `key` over the SHA-256 of `wasm_bytes`,
|
|
/// with the given write-permission grants.
|
|
fn signed_manifest(
|
|
wasm_bytes: &[u8],
|
|
key: &SigningKey,
|
|
perms: &[&str],
|
|
) -> PluginManifest {
|
|
use sha2::{Digest, Sha256};
|
|
let digest: [u8; 32] = Sha256::digest(wasm_bytes).into();
|
|
let sig = key.sign(&digest);
|
|
let mut m = PluginManifest::parse_json(
|
|
r#"{"domain":"demo","name":"Demo","version":"1.0.0"}"#,
|
|
)
|
|
.unwrap();
|
|
m.wasm_module = Some("demo.wasm".into());
|
|
m.wasm_module_hash = Some(encode_sha256(wasm_bytes));
|
|
m.wasm_module_sig = Some(encode_signature(&sig));
|
|
m.publisher_key = Some(encode_verifying_key(&key.verifying_key()));
|
|
m.homecore_permissions = perms.iter().map(|s| s.to_string()).collect();
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn p4_valid_sig_from_trusted_key_loads() {
|
|
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
let key = publisher_key();
|
|
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
|
|
let policy =
|
|
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
|
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
let hc = HomeCore::new();
|
|
rt.load_plugin(&manifest, &wasm, hc, &policy)
|
|
.expect("a validly-signed, trusted plugin must load");
|
|
}
|
|
|
|
#[test]
|
|
fn p4_tampered_module_is_rejected() {
|
|
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
let key = publisher_key();
|
|
// Manifest signs the original bytes; we then load DIFFERENT bytes.
|
|
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
|
|
let policy =
|
|
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
|
|
|
// Re-compile a byte-different module (writes "off" not "on").
|
|
let tampered_src = WRITE_LIGHT_WAT.replace(r#""on""#, r#""of""#);
|
|
let tampered = wat::parse_str(&tampered_src).expect("WAT");
|
|
assert_ne!(wasm, tampered, "test bug: bytes must differ");
|
|
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
let hc = HomeCore::new();
|
|
match rt.load_plugin(&manifest, &tampered, hc, &policy) {
|
|
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
|
Ok(_) => panic!("tampered module must be rejected (hash mismatch), but it loaded"),
|
|
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn p4_valid_sig_from_untrusted_key_is_rejected() {
|
|
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
// Correctly signed by the untrusted key — but it is not on the allowlist.
|
|
let manifest = signed_manifest(&wasm, &untrusted_key(), &["light.*"]);
|
|
let policy =
|
|
PluginPolicy::trusted(&[&encode_verifying_key(&publisher_key().verifying_key())])
|
|
.unwrap();
|
|
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
let hc = HomeCore::new();
|
|
match rt.load_plugin(&manifest, &wasm, hc, &policy) {
|
|
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
|
Ok(_) => panic!("untrusted publisher must be rejected, but it loaded"),
|
|
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned() {
|
|
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
let mut manifest = PluginManifest::parse_json(
|
|
r#"{"domain":"u","name":"U","version":"1"}"#,
|
|
)
|
|
.unwrap();
|
|
manifest.wasm_module = Some("u.wasm".into());
|
|
manifest.homecore_permissions = vec!["light.*".into()];
|
|
// No hash/sig/key → unsigned.
|
|
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
// Secure default: rejected.
|
|
match rt.load_plugin(&manifest, &wasm, HomeCore::new(), &PluginPolicy::deny_all()) {
|
|
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
|
Ok(_) => panic!("unsigned module must be rejected under the secure default"),
|
|
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
|
}
|
|
// Dev escape hatch: loads (with a loud warn).
|
|
rt.load_plugin(
|
|
&manifest,
|
|
&wasm,
|
|
HomeCore::new(),
|
|
&PluginPolicy::AllowUnsigned,
|
|
)
|
|
.expect("AllowUnsigned dev policy must load an unsigned module");
|
|
}
|
|
|
|
// ── ADR-162 P5: authority / capability isolation ────────────────────────
|
|
//
|
|
// FAILS on the pre-ADR-162 code, where `hc_state_set` ignored
|
|
// `homecore_permissions` entirely and let any plugin write any entity.
|
|
|
|
/// Module that writes `lock.front_door` on setup (an over-privileged
|
|
/// write a `light.*` plugin must NOT be allowed to perform).
|
|
const WRITE_LOCK_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 512))
|
|
(data (i32.const 0) "lock.front_door")
|
|
(data (i32.const 64) "unlocked")
|
|
(data (i32.const 128) "{}")
|
|
(func (export "alloc") (param i32) (result i32)
|
|
(local $p i32)
|
|
(local.set $p (global.get $bump))
|
|
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
|
(local.get $p))
|
|
(func (export "dealloc") (param i32 i32))
|
|
;; plugin_setup returns the hc_state_set result code so the host test can
|
|
;; assert the guest saw the typed permission-denied error (-3).
|
|
(func (export "plugin_setup") (param i32 i32) (result i32)
|
|
(call $hc_state_set
|
|
(i32.const 0) (i32.const 15) ;; "lock.front_door"
|
|
(i32.const 64) (i32.const 8) ;; "unlocked"
|
|
(i32.const 128) (i32.const 2))) ;; "{}"
|
|
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
|
)
|
|
"#;
|
|
|
|
#[test]
|
|
fn p5_declared_light_plugin_may_write_light_but_not_lock() {
|
|
let key = publisher_key();
|
|
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
|
|
// (a) A `light.*` plugin writing `light.kitchen` → ALLOWED.
|
|
let light_wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
let light_manifest = signed_manifest(&light_wasm, &key, &["light.*"]);
|
|
let hc_a = HomeCore::new();
|
|
let plugin_a = rt
|
|
.load_plugin(&light_manifest, &light_wasm, hc_a.clone(), &trusted)
|
|
.expect("light plugin loads");
|
|
let r = plugin_a.call_setup("{}").expect("setup");
|
|
assert_eq!(r, 0, "write to declared light.kitchen should succeed");
|
|
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
|
|
assert_eq!(
|
|
hc_a.states().get(&kitchen).expect("light.kitchen written").state,
|
|
"on"
|
|
);
|
|
|
|
// (b) The SAME `light.*` plugin attempting to write `lock.front_door`
|
|
// → REJECTED with the typed -3 code, and the lock is NOT written.
|
|
let lock_wasm = wat::parse_str(WRITE_LOCK_WAT).expect("WAT");
|
|
let lock_manifest = signed_manifest(&lock_wasm, &key, &["light.*"]);
|
|
let hc_b = HomeCore::new();
|
|
let plugin_b = rt
|
|
.load_plugin(&lock_manifest, &lock_wasm, hc_b.clone(), &trusted)
|
|
.expect("module loads (verification ok); the WRITE is what's gated");
|
|
let denied = plugin_b.call_setup("{}").expect("setup runs without trapping host");
|
|
assert_eq!(
|
|
denied, -3,
|
|
"over-privileged write to lock.front_door must return -3 (permission denied)"
|
|
);
|
|
let lock = homecore::EntityId::parse("lock.front_door").unwrap();
|
|
assert!(
|
|
hc_b.states().get(&lock).is_none(),
|
|
"lock.front_door must NOT have been written by a light-only plugin"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn p5_plugin_with_no_permissions_can_write_nothing() {
|
|
let key = publisher_key();
|
|
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
|
let rt = WasmtimeRuntime::new().expect("rt");
|
|
|
|
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
|
// No permissions declared at all.
|
|
let manifest = signed_manifest(&wasm, &key, &[]);
|
|
let hc = HomeCore::new();
|
|
let plugin = rt
|
|
.load_plugin(&manifest, &wasm, hc.clone(), &trusted)
|
|
.expect("module loads; the write is gated");
|
|
// WRITE_LIGHT_WAT drops the host-import result and returns 0, so we
|
|
// assert the denial via the side-effect: the write must NOT land.
|
|
plugin.call_setup("{}").expect("setup runs without trapping host");
|
|
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
|
|
assert!(
|
|
hc.states().get(&kitchen).is_none(),
|
|
"no-permission plugin must not write light.kitchen (P5 authority isolation)"
|
|
);
|
|
}
|
|
}
|