fix(plugins): refuse components that don't link against the host in check and install (#1471)

check_component only compared import names and the API version, so a component
built from unreleased WIT (a function added under @1.0.0) or missing the plugin
world's exports passed `openidea-plugin check` and installing, then failed when
the IDE started it. It now links the component against the host's linker and
reports the missing or mismatched function with what to do.

Closes #1468
This commit is contained in:
l0ng-ai
2026-11-05 14:07:16 +08:00
committed by GitHub
parent 0d80e2c05a
commit 04fe65328e
5 changed files with 95 additions and 11 deletions
Generated
+32 -3
View File
@@ -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"
+1
View File
@@ -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"
+15 -7
View File
@@ -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<Option<semver::Version>, String> {
let ty = component.component_type();
let imports: Vec<String> = 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)?;
@@ -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<Option<semver::Version>, 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}");
}
}
+4 -1
View File
@@ -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 `<app-data>/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