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

+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);