diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 37301f57..68f71e59 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -4,16 +4,99 @@ Complete guide for developers contributing to or extending StarForge. ## Table of Contents -1. [Getting Started](#getting-started) -2. [Development Setup](#development-setup) -3. [Project Structure](#project-structure) -4. [Code Style Guide](#code-style-guide) -5. [Adding New Features](#adding-new-features) -6. [Testing](#testing) -7. [Documentation](#documentation) -8. [Common Tasks](#common-tasks) -9. [Debugging](#debugging) -10. [Release Process](#release-process) +1. [Plugin Version Compatibility](#plugin-version-compatibility) +2. [Getting Started](#getting-started) +3. [Development Setup](#development-setup) +4. [Project Structure](#project-structure) +5. [Code Style Guide](#code-style-guide) +6. [Adding New Features](#adding-new-features) +7. [Testing](#testing) +8. [Documentation](#documentation) +9. [Common Tasks](#common-tasks) +10. [Debugging](#debugging) +11. [Release Process](#release-process) + +--- + +## Plugin Version Compatibility + +StarForge enforces version compatibility when loading plugins to prevent subtle +runtime failures caused by ABI or API mismatches. + +### How it works + +Every plugin shared library must export a `PLUGIN_DECLARATION` symbol (provided +automatically by the `export_plugin!` macro). When `starforge plugin load` runs, +the loader checks two fields in that declaration: + +| Field | What is checked | Failure behaviour | +|---|---|---| +| `rustc_version` | Must match the exact rustc version used to build StarForge | Hard error — load aborted | +| `core_version` | **Major** version must match StarForge's own `CARGO_PKG_VERSION` | Hard error — load aborted | + +The compatibility rule for `core_version` follows semantic versioning: + +- `0.x.y` plugins are **only** compatible with a `0.x.y` StarForge core (major `0`). +- `1.x.y` plugins are **only** compatible with a `1.x.y` StarForge core (major `1`). +- Minor and patch bumps within the same major are considered backwards-compatible. + +### Error messages + +When a plugin fails the version check you will see a clear message, for example: + +``` +Error: Plugin version incompatibility in 'libmy_plugin.so': + Plugin was built for StarForge 0.1.0 + Running StarForge 1.0.0 + + The major version must match. Rebuild the plugin against + StarForge 1.0.0 or install a compatible StarForge version. + See DEVELOPER_GUIDE.md § "Plugin Version Compatibility" for details. +``` + +### Writing a compatible plugin + +1. **Pin the StarForge version** in your plugin's `Cargo.toml`: + + ```toml + [dependencies] + # Use the same major version as the StarForge binary your users will run. + starforge = "0.1" + ``` + +2. **Use the `export_plugin!` macro** — it embeds both `rustc_version` and + `core_version` automatically at compile time: + + ```rust + use starforge::export_plugin; + + export_plugin!(register); + + fn register(registrar: &mut dyn starforge::plugins::PluginRegistrar) { + registrar.register_plugin(Box::new(MyPlugin)); + } + ``` + +3. **Rebuild when StarForge's major version changes.** Check the running version + with `starforge --version` and compare it to the version your plugin was built + against (shown in `starforge plugin load` output under "Built for StarForge"). + +4. **Use the same Rust toolchain** as the StarForge binary. The easiest way is + to keep a `rust-toolchain.toml` in your plugin repo that mirrors the one in + the StarForge repo. + +### Checking compatibility without loading + +```bash +# See which StarForge version is running +starforge --version + +# See which version each installed plugin was built for +starforge plugin load +``` + +The `load` command prints a "Built for StarForge" line for every successfully +loaded plugin, and a descriptive error for any that fail the check. --- diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 03019551..7088e9f6 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,3 +1,4 @@ +use crate::plugins::interface::CORE_VERSION; use crate::plugins::{registry, PluginManager}; use crate::utils::print as p; use anyhow::{Context, Result}; @@ -58,6 +59,7 @@ fn list() -> Result<()> { return Ok(()); } + p::kv("StarForge core version", CORE_VERSION); p::separator(); for (i, pl) in reg.plugins.iter().enumerate() { println!(" {:>2}. {}", i + 1, pl.name); @@ -93,9 +95,11 @@ fn load() -> Result<()> { return Ok(()); } + p::kv("StarForge core version", CORE_VERSION); p::separator(); - for (name, desc) in loaded { + for (name, desc, built_for) in loaded { p::kv_accent(name, desc); + p::kv("Built for StarForge", built_for); } p::separator(); Ok(()) diff --git a/src/plugins/interface.rs b/src/plugins/interface.rs index 2aabfb3f..d1cb4090 100644 --- a/src/plugins/interface.rs +++ b/src/plugins/interface.rs @@ -4,10 +4,10 @@ pub trait Plugin: Any + Send + Sync { fn name(&self) -> &'static str; fn version(&self) -> &'static str; fn description(&self) -> &'static str; - + fn on_load(&self) {} fn on_unload(&self) {} - + fn execute(&self, args: &[String]) -> Result<(), String>; } @@ -26,13 +26,57 @@ macro_rules! export_plugin { ($register:expr) => { #[doc(hidden)] #[no_mangle] - pub static PLUGIN_DECLARATION: $crate::plugins::PluginDeclaration = $crate::plugins::PluginDeclaration { - rustc_version: $crate::plugins::interface::RUSTC_VERSION, - core_version: $crate::plugins::interface::CORE_VERSION, - register: $register, - }; + pub static PLUGIN_DECLARATION: $crate::plugins::PluginDeclaration = + $crate::plugins::PluginDeclaration { + rustc_version: $crate::plugins::interface::RUSTC_VERSION, + core_version: $crate::plugins::interface::CORE_VERSION, + register: $register, + }; }; } pub const RUSTC_VERSION: &str = env!("RUSTC_VERSION"); pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Extract the major version component from a semver string (e.g. "1.2.3" → "1"). +/// Returns the full string unchanged if it cannot be parsed. +fn major(version: &str) -> &str { + match version.find('.') { + Some(pos) => &version[..pos], + None => version, + } +} + +/// Returns `true` when `plugin_version` is compatible with the running StarForge core. +/// +/// Compatibility rule: the **major** version must match exactly. A plugin built +/// against `0.x.y` is incompatible with a core running `1.x.y`, and vice-versa. +/// Patch and minor bumps within the same major are considered backwards-compatible. +pub fn is_core_version_compatible(plugin_version: &str) -> bool { + major(plugin_version) == major(CORE_VERSION) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_version_is_compatible() { + assert!(is_core_version_compatible(CORE_VERSION)); + } + + #[test] + fn different_major_is_incompatible() { + // Construct a version with a different major than CORE_VERSION. + let core_major: u64 = major(CORE_VERSION).parse().unwrap_or(0); + let other = format!("{}.0.0", core_major + 1); + assert!(!is_core_version_compatible(&other)); + } + + #[test] + fn same_major_different_minor_is_compatible() { + let core_major = major(CORE_VERSION); + let other = format!("{}.99.0", core_major); + assert!(is_core_version_compatible(&other)); + } +} diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 3616f46e..8685acef 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -1,4 +1,7 @@ -use crate::plugins::interface::{Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION, RUSTC_VERSION}; +use crate::plugins::interface::{ + is_core_version_compatible, Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION, + RUSTC_VERSION, +}; use anyhow::{Context, Result}; use libloading::{Library, Symbol}; use std::collections::HashMap; @@ -6,7 +9,8 @@ use std::ffi::OsStr; use std::rc::Rc; pub struct PluginManager { - plugins: HashMap>, + /// Maps plugin name → (plugin, core_version it was built against). + plugins: HashMap, String)>, libraries: Vec>, } @@ -25,37 +29,56 @@ impl PluginManager { } /// # Safety - /// The caller must ensure the plugin at `path` is a valid starforge plugin + /// The caller must ensure the plugin at `path` is a valid StarForge plugin /// compiled with a compatible Rust toolchain and ABI. pub unsafe fn load_plugin>(&mut self, path: P) -> Result<()> { - let library = Rc::new(Library::new(path).context("Failed to load library")?); + let path_display = path.as_ref().to_string_lossy().to_string(); + let library = + Rc::new(Library::new(path).context("Failed to load library")?); let decl: Symbol<*mut PluginDeclaration> = library .get(b"PLUGIN_DECLARATION") - .context("Failed to find PLUGIN_DECLARATION symbol")?; + .context("Failed to find PLUGIN_DECLARATION symbol — is this a StarForge plugin?")?; let decl = &**decl; + // ── rustc ABI check ────────────────────────────────────────────────── if decl.rustc_version != RUSTC_VERSION { anyhow::bail!( - "Plugin rustc version mismatch: expected {}, found {}", - RUSTC_VERSION, - decl.rustc_version + "Plugin ABI mismatch in '{path_display}':\n \ + Plugin was compiled with rustc {plugin_rustc}\n \ + StarForge requires rustc {core_rustc}\n\n \ + Rebuild the plugin with the same Rust toolchain used to build StarForge.", + path_display = path_display, + plugin_rustc = decl.rustc_version, + core_rustc = RUSTC_VERSION, ); } - if decl.core_version != CORE_VERSION { - // We could be more lenient here, but let's be strict for now - println!("Warning: Plugin core version mismatch: core={}, plugin={}", CORE_VERSION, decl.core_version); + // ── StarForge core version check ───────────────────────────────────── + if !is_core_version_compatible(decl.core_version) { + anyhow::bail!( + "Plugin version incompatibility in '{path_display}':\n \ + Plugin was built for StarForge {plugin_core}\n \ + Running StarForge {core}\n\n \ + The major version must match. Rebuild the plugin against \ + StarForge {core} or install a compatible StarForge version.\n \ + See DEVELOPER_GUIDE.md § \"Plugin Version Compatibility\" for details.", + path_display = path_display, + plugin_core = decl.core_version, + core = CORE_VERSION, + ); } let mut registrar = ProxyRegistrar::new(); (decl.register)(&mut registrar); + let plugin_core_version = decl.core_version.to_string(); for plugin in registrar.plugins { let name = plugin.name().to_string(); plugin.on_load(); - self.plugins.insert(name, plugin); + self.plugins + .insert(name, (plugin, plugin_core_version.clone())); } self.libraries.push(library); @@ -63,12 +86,16 @@ impl PluginManager { Ok(()) } - pub fn list_plugins(&self) -> Vec<(&str, &str)> { - self.plugins.iter().map(|(n, p)| (n.as_str(), p.description())).collect() + /// Returns `(name, description, built_for_core_version)` for every loaded plugin. + pub fn list_plugins(&self) -> Vec<(&str, &str, &str)> { + self.plugins + .iter() + .map(|(n, (p, cv))| (n.as_str(), p.description(), cv.as_str())) + .collect() } - + pub fn execute(&self, name: &str, args: &[String]) -> Result<(), String> { - if let Some(plugin) = self.plugins.get(name) { + if let Some((plugin, _)) = self.plugins.get(name) { plugin.execute(args) } else { Err(format!("Plugin '{}' not found", name))