diff --git a/Cargo.lock b/Cargo.lock index 317a7da6..fff5f8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] @@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238" dependencies = [ "bstr", - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] @@ -4504,6 +4504,7 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", + "wat", ] [[package]] @@ -8362,7 +8363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a" dependencies = [ "foldhash", - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] @@ -10305,6 +10306,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + [[package]] name = "universal-hash" version = "0.6.1" @@ -10825,6 +10832,28 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "wast" +version = "258.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index 0c3bf091..da304e85 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2" [dev-dependencies] tempfile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } +wat = "1.258" diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 21bfb569..19d97abb 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -211,8 +211,10 @@ impl PluginEngine { /// Rejects a component this host can't run, with a message that says what /// to do: one importing interfaces outside the plugin API and WASI, or one /// built for a plugin API this host doesn't implement (another major, or a - /// newer minor). Returns the API version the component was built for - /// (`None`: it uses no plugin interface). + /// newer minor), or one that doesn't link: an import the host lacks or + /// declares with another type (same API version, different WIT), or a + /// missing plugin export. Returns the API version the component was built + /// for (`None`: it uses no plugin interface). pub fn check_component(&self, component: &Component) -> Result, String> { let ty = component.component_type(); let imports: Vec = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect(); @@ -229,10 +231,20 @@ impl PluginEngine { if let Some(v) = &built_for { check_api(v, &host_api())?; } + let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?; + bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?; Ok(built_for) } } +/// Explains a component that doesn't link against this host's plugin API. +fn link_error(detail: &str) -> String { + format!( + "the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \ + If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK" + ) +} + /// Interface names of the plugin API start with this (`openidea:plugin/log@1.0.0`). const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/"; @@ -542,11 +554,7 @@ impl Runtime { arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?; let api_hint = |e: wasmtime::Error| { let msg = describe_trap(&e, &setup.limits); - if msg.contains("openidea:plugin/") { - format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})") - } else { - msg - } + if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg } }; let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; diff --git a/crates/ide-plugin/tests/check_component.rs b/crates/ide-plugin/tests/check_component.rs new file mode 100644 index 00000000..fd3760c9 --- /dev/null +++ b/crates/ide-plugin/tests/check_component.rs @@ -0,0 +1,43 @@ +//! `PluginEngine::check_component` (what installing and `openidea-plugin check` +//! run) refuses a component the IDE could not start: every import has to link +//! against the host, function by function, and the plugin world's exports have +//! to be there, not only the interface names and their API version. + +use ide_plugin::PluginEngine; + +fn check(wat: &str) -> Result, String> { + let engine = PluginEngine::new().unwrap(); + let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap(); + engine.check_component(&component) +} + +#[test] +fn components_the_host_cannot_link_are_refused() { + let cases = [ + // dengliu.gitlink 0.1.0: functions added to `ui` on an unreleased branch, still at @1.0.0. + ( + "a function the host's interface lacks", + r#"(component (import "openidea:plugin/ui@1.0.0" (instance + (export "copy-to-clipboard" (func (param "text" string))))))"#, + "copy-to-clipboard", + ), + ( + "a function with another signature", + r#"(component (import "openidea:plugin/ui@1.0.0" (instance + (export "show-notification" (func (param "title" string))))))"#, + "show-notification", + ), + ( + "an interface the host lacks", + r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance + (export "copy" (func (param "text" string))))))"#, + "openidea:plugin/clipboard@1.0.0", + ), + ("no plugin exports", r#"(component (import "openidea:plugin/ui@1.0.0" (instance)))"#, "lifecycle"), + ]; + for (case, wat, names) in cases { + let err = check(wat).expect_err(case); + assert!(err.contains(names), "{case}: {err}"); + assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}"); + } +} diff --git a/plugins/README.md b/plugins/README.md index a43761b5..6af92fd0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -71,7 +71,8 @@ target through rustup if needed), copies the component to the manifest's `wasm` path and runs `check`. `check` validates the package exactly as _Install Plugin from Disk…_ would — manifest, every contribution, the SVG sanitiser, the component — and that the component imports nothing but the -plugin API and WASI. It prints what the user will be asked to grant. +plugin API and WASI and links against the IDE's: every function it imports +exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant. `link` adds the folder to `links.json` in the IDE's plugins directory (`$OPENIDEA_PLUGINS_DIR`, default `/ai.l0ng.openidea/plugins`). Linked @@ -944,6 +945,8 @@ only grows: its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`). Installing and `openidea-plugin check` refuse another major or a newer minor with a message that says to update the IDE or rebuild with the SDK. + They also link the component, so one that imports a function this host + lacks under the same version (built from unreleased WIT) is refused too. 5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and `API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`. 6. **1.x calls are synchronous and pass documents whole.** Host calls block