feat(plugins): include rustc version in plugin compatibility gate

OpenCADStudio/plugins#901

- Record the host's rustc --version output in the embedded version_info.json.
- Add rustc_version/rustc_declared/rustc_compatible to plugin manifests and
  release metadata, mirroring the existing acadrust gate.
- Reject API v4+ plugins whose declared rustc differs from the host, with a
  clear one-line error message.
- Propagate the rustc gate through the marketplace, install flow, and UI.
- Update plugin architecture docs to describe the new ABI metadata.
This commit is contained in:
Sebastian committed 2026-08-29 10:34:47 +02:00
1 parent 3024869de0
commit ed3e064a47
7 files changed
+344 -7

No files matched your search

+4 -2
View File
@@ -50,10 +50,11 @@ The host and each plugin run in separate OS processes. The host re-executes itse
- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end.
- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)).
The runtime enforces two gates:
The runtime enforces three gates:
1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`.
2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true.
3. For v4+, when the plugin declares `rustc_version`, `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true.
---
@@ -215,7 +216,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p
The crate embeds two JSON blobs at build time:
- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs).
- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs).
- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs).
Both are accessible without enabling the `host` feature.
@@ -242,6 +243,7 @@ This is defense-in-depth, not a sandbox: plugins execute native code.
| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. |
| Version mismatch | Host refuses plugin before running plugin code. |
| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. |
| Rustc mismatch (v4+) | Host refuses plugin to avoid cross-compiler ABI crashes. |
| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. |
---
+15
View File
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use cargo_lock::Lockfile;
@@ -383,11 +384,25 @@ fn generate_version_info(out_dir: &Path) {
.find(|p| p.name.as_str() == "acadrust")
.expect("acadrust package in Cargo.lock");
let rustc_version = Command::new("rustc")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_default();
let info = serde_json::json!({
"ocs_version": ocs.version.to_string(),
"ocs_plugin_api_version": env!("CARGO_PKG_VERSION"),
"acadrust_version": acadrust.version.to_string(),
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 4,
"api_version_min_supported": 2,
"build_timestamp": build_timestamp,
+62
View File
@@ -11,6 +11,12 @@
//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two
//! sources are considered compatible only when they resolve to the same 40
//! character git commit hash.
//!
//! The rustc version string is also recorded because Rust has no stable ABI:
//! a plugin built with a different compiler than the host can pass simple calls
//! and then crash the runner on compound types. For API v4 and later the host
//! can compare the plugin's declared rustc with its own via
//! [`rustc_versions_compatible`].
use std::sync::OnceLock;
@@ -26,6 +32,7 @@ pub fn get_embedded_version_info_json() -> &'static str {
#[derive(Debug, Clone)]
struct VersionInfo {
acadrust_source: String,
rustc_version: String,
}
pub const ACADRUST_GATE_API_VERSION: u32 = 4;
@@ -48,6 +55,8 @@ fn parsed_version_info() -> &'static VersionInfo {
INFO.get_or_init(|| VersionInfo {
acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source")
.unwrap_or_default(),
rustc_version: json_string_value(EMBEDDED_VERSION_INFO_JSON, "rustc_version")
.unwrap_or_default(),
})
}
@@ -56,6 +65,25 @@ pub fn host_acadrust_source() -> &'static str {
&parsed_version_info().acadrust_source
}
/// Returns the host's `rustc --version` output.
pub fn host_rustc_version() -> &'static str {
&parsed_version_info().rustc_version
}
/// Returns whether two `rustc --version` outputs are considered compatible.
///
/// The full version line (e.g. "rustc 1.98.0 (hash date)") is compared
/// verbatim after normalising whitespace. This is intentionally strict: Rust
/// has no stable ABI, so even the same nominal compiler built in different
/// environments can produce incompatible layouts.
pub fn rustc_versions_compatible(a: &str, b: &str) -> bool {
if a.is_empty() || b.is_empty() {
return false;
}
a.split_whitespace().collect::<String>()
.eq_ignore_ascii_case(&b.split_whitespace().collect::<String>())
}
/// Extracts the full git commit hash from a Cargo source.
pub fn acadrust_source_hash(source: &str) -> Option<&str> {
let hash = source.rsplit('#').next()?;
@@ -85,6 +113,7 @@ mod tests {
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
assert!(value.get("ocs_version").is_some());
assert!(value.get("acadrust_source").is_some());
assert!(value.get("rustc_version").is_some());
}
#[test]
@@ -96,6 +125,7 @@ mod tests {
"ocs_plugin_api_version",
"acadrust_version",
"acadrust_source",
"rustc_version",
"api_version",
"api_version_min_supported",
"build_timestamp",
@@ -125,6 +155,38 @@ mod tests {
assert!(!host_acadrust_source().is_empty());
}
#[test]
fn host_rustc_version_is_non_empty() {
assert!(!host_rustc_version().is_empty());
}
#[test]
fn rustc_version_comparison_is_strict() {
assert!(rustc_versions_compatible(
"rustc 1.98.0 (abc123 2026-01-01)",
"rustc 1.98.0 (abc123 2026-01-01)"
));
assert!(!rustc_versions_compatible(
"rustc 1.96.0 (def456 2025-11-01)",
"rustc 1.98.0 (abc123 2026-01-01)"
));
}
#[test]
fn rustc_version_comparison_normalises_whitespace() {
assert!(rustc_versions_compatible(
"rustc 1.98.0 (abc123 2026-01-01)",
"rustc 1.98.0 (abc123 2026-01-01)"
));
}
#[test]
fn rustc_version_comparison_rejects_empty() {
assert!(!rustc_versions_compatible("", "rustc 1.98.0"));
assert!(!rustc_versions_compatible("rustc 1.98.0", ""));
assert!(!rustc_versions_compatible("", ""));
}
#[test]
fn dependency_gate_starts_at_api_v4() {
assert!(!uses_acadrust_gate(3));
+25
View File
@@ -89,6 +89,25 @@ plugin compiles against. Two tiers:
A plugin enables the `host` feature.
### Build-time ABI metadata
For API v4 and later the host records two pieces of build-time metadata that
affect whether a prebuilt plugin can safely be loaded:
- `acadrust_source` — the exact git source of the `acadrust` crate the host was
built against.
- `rustc_version` — the output of `rustc --version` for the compiler that built
the host.
A plugin's `plugin.toml` may declare the same values under `[opencad]`. When
either is declared, the host refuses to load the plugin unless it matches the
host exactly. This turns the current silent runner crash into a one-line
diagnosis such as:
```
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
```
### `PluginManifest`
```rust
@@ -161,6 +180,10 @@ ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", featur
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
```
Your release build must also use the same `rustc` as the host. Record it in
`plugin.toml` (see below) and pin the toolchain in your CI matrix so every
published asset matches the host's compiler.
```rust
// src/lib.rs
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
@@ -210,6 +233,8 @@ api_version = 3
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
# For API v4+, also record the exact rustc used to build the release asset.
# rustc_version = "rustc 1.98.0 (hash date)"
```
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
+162 -1
View File
@@ -36,6 +36,12 @@ pub struct ReleaseInfo {
pub acadrust_declared: bool,
/// Whether the release matches this host.
pub acadrust_compatible: bool,
/// Full `rustc --version` output used by the release.
pub rustc_version: Option<String>,
/// Whether `[opencad]` declares `rustc_version`.
pub rustc_declared: bool,
/// Whether the release's rustc matches this host.
pub rustc_compatible: bool,
}
/// An add-on package found on disk (not necessarily loaded or compatible).
@@ -54,6 +60,10 @@ pub struct ExternalPlugin {
pub acadrust_source: Option<String>,
/// Whether `[opencad]` declares `acadrust_source`.
pub acadrust_declared: bool,
/// Full `rustc --version` output used by the plugin.
pub rustc_version: Option<String>,
/// Whether `[opencad]` declares `rustc_version`.
pub rustc_declared: bool,
pub ribbon_order: i32,
pub command_prefixes: Vec<String>,
/// The package directory under the plugins folder.
@@ -85,10 +95,30 @@ impl ExternalPlugin {
}
}
/// Returns whether the package's rustc matches the host.
pub fn rustc_compatible(&self) -> bool {
if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) {
return true;
}
if !self.rustc_declared {
return true;
}
match self.rustc_version.as_deref() {
None | Some("") => false,
Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible(
version,
ocs_plugin_api::version_info::host_rustc_version(),
),
}
}
/// Returns whether the package can be loaded.
#[allow(dead_code)] // plugin-host surface (issue #100); not yet wired
pub fn loadable(&self) -> bool {
self.api_compatible() && self.acadrust_compatible() && self.lib_present
self.api_compatible()
&& self.acadrust_compatible()
&& self.rustc_compatible()
&& self.lib_present
}
}
@@ -204,6 +234,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
let mut api_version: u32 = 0;
let mut acadrust_source: Option<String> = None;
let mut acadrust_declared = false;
let mut rustc_version: Option<String> = None;
let mut rustc_declared = false;
let mut ribbon_order: i32 = 0;
let mut command_prefixes: Vec<String> = Vec::new();
let mut section = None;
@@ -236,6 +268,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
let v = unquote(value);
acadrust_source = if v.is_empty() { None } else { Some(v) };
}
"rustc_version" if section == Some("opencad") => {
rustc_declared = true;
let v = unquote(value);
rustc_version = if v.is_empty() { None } else { Some(v) };
}
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
"command_prefixes" => command_prefixes = parse_string_array(value),
_ => {}
@@ -251,6 +288,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
api_version,
acadrust_source,
acadrust_declared,
rustc_version,
rustc_declared,
ribbon_order,
command_prefixes,
dir: PathBuf::new(),
@@ -372,6 +411,17 @@ mod loader {
));
continue;
}
if !d.rustc_compatible() {
let host_rustc = ocs_plugin_api::version_info::host_rustc_version();
let plugin_rustc = d.rustc_version.as_deref().unwrap_or("unknown");
out.push((
d.id.clone(),
Err(format!(
"Plugin built with {plugin_rustc}, host requires {host_rustc} - rebuild required"
)),
));
continue;
}
let Some(path) = lib_file(&d.dir) else {
out.push((
d.id.clone(),
@@ -483,6 +533,117 @@ xdata_apps = ["MYPLUGIN_RECORD"]
assert!(!p.loadable());
}
#[test]
fn undeclared_rustc_falls_back_to_api_gate() {
let toml = r#"
[plugin]
id = "opencad.test"
name = "Test"
version = "0.1.0"
api_version = 4
"#;
let p = parse_plugin_toml(toml).expect("parsed");
assert!(!p.rustc_declared);
assert!(p.rustc_version.is_none());
assert!(p.rustc_compatible(), "undeclared rustc is treated as compatible");
}
#[test]
fn declared_empty_rustc_version_is_incompatible() {
let toml = r#"
[plugin]
id = "opencad.test"
name = "Test"
version = "0.1.0"
api_version = 4
[opencad]
rustc_version = ""
"#;
let p = parse_plugin_toml(toml).expect("parsed");
assert!(p.rustc_declared);
assert!(p.rustc_version.is_none());
assert!(!p.rustc_compatible(), "declared but empty rustc is incompatible");
assert!(!p.loadable());
}
#[test]
fn rustc_version_outside_opencad_is_ignored() {
let toml = r#"
[plugin]
id = "opencad.test"
api_version = 4
rustc_version = "rustc 1.98.0"
"#;
let p = parse_plugin_toml(toml).expect("parsed");
assert!(!p.rustc_declared);
assert!(p.rustc_version.is_none());
}
#[test]
fn rustc_mismatch_detected() {
let _host = ocs_plugin_api::version_info::host_rustc_version();
let other = "rustc 0.0.0-fake (not matching anything)";
let toml = format!(
r#"
[plugin]
id = "opencad.test"
name = "Test"
version = "0.1.0"
api_version = 4
[opencad]
rustc_version = "{other}"
"#
);
let p = parse_plugin_toml(&toml).expect("parsed");
assert!(p.rustc_declared);
assert!(!p.rustc_compatible(), "mismatched rustc should be incompatible");
assert!(!p.loadable());
}
#[test]
fn rustc_match_detected() {
let host = ocs_plugin_api::version_info::host_rustc_version();
let toml = format!(
r#"
[plugin]
id = "opencad.test"
name = "Test"
version = "0.1.0"
api_version = 4
[opencad]
rustc_version = "{host}"
"#
);
let p = parse_plugin_toml(&toml).expect("parsed");
assert!(p.rustc_declared);
assert!(p.rustc_compatible(), "matching rustc should be compatible");
}
#[test]
fn rustc_gate_only_applies_to_api_v4_and_newer() {
let toml = r#"
[plugin]
id = "opencad.test"
name = "Test"
version = "0.1.0"
api_version = 2
[opencad]
rustc_version = "rustc 0.0.0-fake"
"#;
let mut p = parse_plugin_toml(toml).expect("parsed");
p.lib_present = true;
assert!(p.rustc_declared);
assert!(
p.rustc_compatible(),
"API v2 plugin should bypass rustc gate"
);
assert!(p.loadable());
}
#[test]
fn undeclared_acadrust_falls_back_to_api_gate() {
let toml = r#"
+56 -2
View File
@@ -236,12 +236,32 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
),
}
};
let rustc_version = manifest.rustc_version.clone();
let rustc_declared = manifest.rustc_declared;
let rustc_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate(
manifest.api_version,
) {
true
} else if !rustc_declared {
true
} else {
match rustc_version.as_deref() {
None | Some("") => false,
Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible(
version,
ocs_plugin_api::version_info::host_rustc_version(),
),
}
};
Ok::<_, String>(ReleaseInfo {
tag: release.tag,
api_version: manifest.api_version,
acadrust_source,
acadrust_declared,
acadrust_compatible,
rustc_version,
rustc_declared,
rustc_compatible,
})
})();
match result {
@@ -251,8 +271,8 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
}
// Once a repo declares a fingerprint, require it on later API versions.
let any_declared = info.iter().any(|r| r.acadrust_declared);
if any_declared {
let any_acadrust_declared = info.iter().any(|r| r.acadrust_declared);
if any_acadrust_declared {
for r in &mut info {
if !r.acadrust_declared
&& ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version)
@@ -261,6 +281,16 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
}
}
}
let any_rustc_declared = info.iter().any(|r| r.rustc_declared);
if any_rustc_declared {
for r in &mut info {
if !r.rustc_declared
&& ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version)
{
r.rustc_compatible = false;
}
}
}
if info.is_empty() {
Err(last_error.unwrap_or_else(|| "no installable releases found".to_string()))
@@ -361,6 +391,30 @@ pub fn install(release: &Release, repository: &str) -> Result<String, String> {
}
}
if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version)
&& manifest.rustc_declared
{
let Some(version) = manifest.rustc_version.as_deref() else {
return Err(
"Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(),
);
};
if version.is_empty() {
return Err(
"Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(),
);
}
if !ocs_plugin_api::version_info::rustc_versions_compatible(
version,
ocs_plugin_api::version_info::host_rustc_version(),
) {
let host_rustc = ocs_plugin_api::version_info::host_rustc_version();
return Err(format!(
"Plugin built with {version}, host requires {host_rustc} - rebuild required"
));
}
}
let dir = external::plugins_dir()
.ok_or("cannot locate the plugins folder")?
.join(&manifest.id);
+20 -2
View File
@@ -203,6 +203,7 @@ fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option<String> {
.filter(|release| {
ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
&& release.acadrust_compatible
&& release.rustc_compatible
})
.filter_map(|release| {
semver::Version::parse(trim_version_prefix(&release.tag))
@@ -225,13 +226,14 @@ fn external_card<'a>(
) -> Element<'a, Message> {
let failed_old_api = load_error.is_some() && !p.api_compatible();
let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible();
let rustc_mismatch = p.rustc_declared && !p.rustc_compatible();
let (status, kind) = if loaded && disabled {
(t!("Disabled"), StatusKind::Muted)
} else if loaded {
(t!("Loaded"), StatusKind::Success)
} else if !p.api_compatible() || failed_old_api {
(t!("API incompatible"), StatusKind::Danger)
} else if acadrust_mismatch {
} else if acadrust_mismatch || rustc_mismatch {
(t!("Incompatible"), StatusKind::Danger)
} else if load_error.is_some() {
(t!("Load failed"), StatusKind::Danger)
@@ -375,7 +377,8 @@ fn install_controls<'a>(
let action = match selected_release {
Some(release)
if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
&& release.acadrust_compatible =>
&& release.acadrust_compatible
&& release.rustc_compatible =>
{
pill_button(
t!("Install"),
@@ -990,6 +993,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
@@ -997,6 +1003,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v1.9.9".to_string(),
@@ -1004,6 +1013,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0"));
@@ -1021,6 +1033,9 @@ mod tests {
),
acadrust_declared: true,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
@@ -1030,6 +1045,9 @@ mod tests {
),
acadrust_declared: true,
acadrust_compatible: false,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0"));