diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 20e77acc8..81b7b429e 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -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. | --- diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 480f2435f..5be2df03e 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -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, diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 35c870cde..4222205cb 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -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::() + .eq_ignore_ascii_case(&b.split_whitespace().collect::()) +} + /// 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)); diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index dc1a8d0aa..d4a97e5a6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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); diff --git a/src/plugin/external.rs b/src/plugin/external.rs index c395b1e59..40f63bd1b 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -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, + /// 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, /// Whether `[opencad]` declares `acadrust_source`. pub acadrust_declared: bool, + /// Full `rustc --version` output used by the plugin. + pub rustc_version: Option, + /// Whether `[opencad]` declares `rustc_version`. + pub rustc_declared: bool, pub ribbon_order: i32, pub command_prefixes: Vec, /// 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 { let mut api_version: u32 = 0; let mut acadrust_source: Option = None; let mut acadrust_declared = false; + let mut rustc_version: Option = None; + let mut rustc_declared = false; let mut ribbon_order: i32 = 0; let mut command_prefixes: Vec = Vec::new(); let mut section = None; @@ -236,6 +268,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { 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 { 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#" diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index f3f6c5f3d..a89f4b4e5 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -236,12 +236,32 @@ pub fn fetch_release_info(repo: &str) -> Result, 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, 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, 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 { } } + 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); diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 1b11c7354..2db1bdd31 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -203,6 +203,7 @@ fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option { .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"));