feat(plugin): gate dependency mismatches
This commit is contained in:
commit
1e1eeea527
8 changed files with 438 additions and 46 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "acadrust"
|
name = "acadrust"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=1fa0a5e#1fa0a5e22f53dd45c6b0b9d8c63f7fc7730a3a8f"
|
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1fa0a5e", features = ["serde"] }
|
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] }
|
||||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] }
|
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] }
|
||||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
|
@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
|
||||||
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||||
|
|
||||||
[patch."https://github.com/HakanSeven12/cadcodec.git"]
|
[patch."https://github.com/HakanSeven12/cadcodec.git"]
|
||||||
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "1fa0a5e" }
|
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" }
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
||||||
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
||||||
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
||||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", optional = true, features = ["serde"] }
|
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", optional = true, features = ["serde"] }
|
||||||
|
|
||||||
# Runtime IPC and serialization (host feature only).
|
# Runtime IPC and serialization (host feature only).
|
||||||
interprocess = { version = "2", optional = true }
|
interprocess = { version = "2", optional = true }
|
||||||
|
|
@ -37,7 +37,7 @@ serde_json = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
cargo-lock = "11"
|
cargo-lock = "11"
|
||||||
# acadrust is scanned at build time to generate the embedded type registry.
|
# acadrust is scanned at build time to generate the embedded type registry.
|
||||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] }
|
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! Embedded version metadata generated at build time.
|
//! Embedded version metadata generated at build time.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// The embedded version info as a JSON string.
|
/// The embedded version info as a JSON string.
|
||||||
pub const EMBEDDED_VERSION_INFO_JSON: &str =
|
pub const EMBEDDED_VERSION_INFO_JSON: &str =
|
||||||
include_str!(concat!(env!("OUT_DIR"), "/version_info.json"));
|
include_str!(concat!(env!("OUT_DIR"), "/version_info.json"));
|
||||||
|
|
@ -9,6 +11,57 @@ pub fn get_embedded_version_info_json() -> &'static str {
|
||||||
EMBEDDED_VERSION_INFO_JSON
|
EMBEDDED_VERSION_INFO_JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct VersionInfo {
|
||||||
|
acadrust_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ACADRUST_GATE_API_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
/// Returns whether this API version uses the dependency gate.
|
||||||
|
pub fn uses_acadrust_gate(api_version: u32) -> bool {
|
||||||
|
api_version >= ACADRUST_GATE_API_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a string from the generated compact JSON.
|
||||||
|
fn json_string_value(json: &str, key: &str) -> Option<String> {
|
||||||
|
let pattern = format!(r#""{key}":""#);
|
||||||
|
let start = json.find(&pattern)? + pattern.len();
|
||||||
|
let end = json[start..].find('"')?;
|
||||||
|
Some(json[start..start + end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsed_version_info() -> &'static VersionInfo {
|
||||||
|
static INFO: OnceLock<VersionInfo> = OnceLock::new();
|
||||||
|
INFO.get_or_init(|| VersionInfo {
|
||||||
|
acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source")
|
||||||
|
.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the host's full `acadrust` Cargo source.
|
||||||
|
pub fn host_acadrust_source() -> &'static str {
|
||||||
|
&parsed_version_info().acadrust_source
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the full git commit hash from a Cargo source.
|
||||||
|
pub fn acadrust_source_hash(source: &str) -> Option<&str> {
|
||||||
|
let hash = source.rsplit('#').next()?;
|
||||||
|
if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
Some(hash)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether two Cargo sources use the same commit.
|
||||||
|
pub fn acadrust_sources_compatible(a: &str, b: &str) -> bool {
|
||||||
|
match (acadrust_source_hash(a), acadrust_source_hash(b)) {
|
||||||
|
(Some(ha), Some(hb)) => ha.eq_ignore_ascii_case(hb),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -19,7 +72,7 @@ mod tests {
|
||||||
assert!(!json.is_empty());
|
assert!(!json.is_empty());
|
||||||
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
|
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
|
||||||
assert!(value.get("ocs_version").is_some());
|
assert!(value.get("ocs_version").is_some());
|
||||||
assert!(value.get("acadrust_version").is_some());
|
assert!(value.get("acadrust_source").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -54,4 +107,57 @@ mod tests {
|
||||||
let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string");
|
let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string");
|
||||||
assert_eq!(acadrust.split('.').count(), 3);
|
assert_eq!(acadrust.split('.').count(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_acadrust_source_is_non_empty() {
|
||||||
|
assert!(!host_acadrust_source().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dependency_gate_starts_at_api_v4() {
|
||||||
|
assert!(!uses_acadrust_gate(3));
|
||||||
|
assert!(uses_acadrust_gate(4));
|
||||||
|
assert!(uses_acadrust_gate(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_full_hash_from_cargo_source() {
|
||||||
|
let src = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd";
|
||||||
|
assert_eq!(
|
||||||
|
acadrust_source_hash(src),
|
||||||
|
Some("94df2c3f87fa051b16ffc3923f80e9247c85c5fd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_or_missing_hash() {
|
||||||
|
assert!(acadrust_source_hash("").is_none());
|
||||||
|
assert!(acadrust_source_hash("registry+https://crates.io").is_none());
|
||||||
|
assert!(acadrust_source_hash("git+https://github.com/foo/bar.git#short").is_none());
|
||||||
|
assert!(
|
||||||
|
acadrust_source_hash("git+https://github.com/foo/bar.git#gggggggggggggggggggggggggggggggggggggggg")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_comparison_matches_full_hashes() {
|
||||||
|
let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd";
|
||||||
|
let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd";
|
||||||
|
assert!(acadrust_sources_compatible(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_comparison_detects_mismatch() {
|
||||||
|
let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd";
|
||||||
|
let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb";
|
||||||
|
assert!(!acadrust_sources_compatible(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_comparison_case_insensitive() {
|
||||||
|
let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd";
|
||||||
|
let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94DF2C3F87FA051B16FFC3923F80E9247C85C5FD";
|
||||||
|
assert!(acadrust_sources_compatible(a, b));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ publish = false
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] }
|
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] }
|
||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ pub struct RegistryEntry {
|
||||||
pub struct ReleaseInfo {
|
pub struct ReleaseInfo {
|
||||||
pub tag: String,
|
pub tag: String,
|
||||||
pub api_version: u32,
|
pub api_version: u32,
|
||||||
|
/// Full `acadrust` git source used by the release.
|
||||||
|
pub acadrust_source: Option<String>,
|
||||||
|
/// Whether `[opencad]` declares `acadrust_source`.
|
||||||
|
pub acadrust_declared: bool,
|
||||||
|
/// Whether the release matches this host.
|
||||||
|
pub acadrust_compatible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An add-on package found on disk (not necessarily loaded or compatible).
|
/// An add-on package found on disk (not necessarily loaded or compatible).
|
||||||
|
|
@ -44,6 +50,10 @@ pub struct ExternalPlugin {
|
||||||
/// an older `plugin.toml` does not declare `repository`.
|
/// an older `plugin.toml` does not declare `repository`.
|
||||||
pub repository: Option<String>,
|
pub repository: Option<String>,
|
||||||
pub api_version: u32,
|
pub api_version: u32,
|
||||||
|
/// Full `acadrust` git source used by the plugin.
|
||||||
|
pub acadrust_source: Option<String>,
|
||||||
|
/// Whether `[opencad]` declares `acadrust_source`.
|
||||||
|
pub acadrust_declared: bool,
|
||||||
pub ribbon_order: i32,
|
pub ribbon_order: i32,
|
||||||
pub command_prefixes: Vec<String>,
|
pub command_prefixes: Vec<String>,
|
||||||
/// The package directory under the plugins folder.
|
/// The package directory under the plugins folder.
|
||||||
|
|
@ -58,11 +68,27 @@ impl ExternalPlugin {
|
||||||
ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version)
|
ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the package can be loaded today: compatible API *and* a native
|
/// Returns whether the package's dependency fingerprint matches the host.
|
||||||
/// library present for this platform.
|
pub fn acadrust_compatible(&self) -> bool {
|
||||||
|
if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !self.acadrust_declared {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match self.acadrust_source.as_deref() {
|
||||||
|
None | Some("") => false,
|
||||||
|
Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible(
|
||||||
|
source,
|
||||||
|
ocs_plugin_api::version_info::host_acadrust_source(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the package can be loaded.
|
||||||
#[allow(dead_code)] // plugin-host surface (issue #100); not yet wired
|
#[allow(dead_code)] // plugin-host surface (issue #100); not yet wired
|
||||||
pub fn loadable(&self) -> bool {
|
pub fn loadable(&self) -> bool {
|
||||||
self.api_compatible() && self.lib_present
|
self.api_compatible() && self.acadrust_compatible() && self.lib_present
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,12 +202,21 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
||||||
let mut description = String::new();
|
let mut description = String::new();
|
||||||
let mut repository = None;
|
let mut repository = None;
|
||||||
let mut api_version: u32 = 0;
|
let mut api_version: u32 = 0;
|
||||||
|
let mut acadrust_source: Option<String> = None;
|
||||||
|
let mut acadrust_declared = false;
|
||||||
let mut ribbon_order: i32 = 0;
|
let mut ribbon_order: i32 = 0;
|
||||||
let mut command_prefixes: Vec<String> = Vec::new();
|
let mut command_prefixes: Vec<String> = Vec::new();
|
||||||
|
let mut section = None;
|
||||||
|
|
||||||
for raw in text.lines() {
|
for raw in text.lines() {
|
||||||
let line = raw.trim();
|
let line = raw.trim();
|
||||||
if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(header) = line.strip_prefix('[') {
|
||||||
|
section = header
|
||||||
|
.find(']')
|
||||||
|
.map(|end| header[..end].trim());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((key, value)) = line.split_once('=') else {
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
|
@ -196,6 +231,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
||||||
"description" => description = unquote(value),
|
"description" => description = unquote(value),
|
||||||
"repository" => repository = normalize_repository(&unquote(value)),
|
"repository" => repository = normalize_repository(&unquote(value)),
|
||||||
"api_version" => api_version = value.parse().unwrap_or(0),
|
"api_version" => api_version = value.parse().unwrap_or(0),
|
||||||
|
"acadrust_source" if section == Some("opencad") => {
|
||||||
|
acadrust_declared = true;
|
||||||
|
let v = unquote(value);
|
||||||
|
acadrust_source = if v.is_empty() { None } else { Some(v) };
|
||||||
|
}
|
||||||
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
|
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
|
||||||
"command_prefixes" => command_prefixes = parse_string_array(value),
|
"command_prefixes" => command_prefixes = parse_string_array(value),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -209,6 +249,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
||||||
description,
|
description,
|
||||||
repository,
|
repository,
|
||||||
api_version,
|
api_version,
|
||||||
|
acadrust_source,
|
||||||
|
acadrust_declared,
|
||||||
ribbon_order,
|
ribbon_order,
|
||||||
command_prefixes,
|
command_prefixes,
|
||||||
dir: PathBuf::new(),
|
dir: PathBuf::new(),
|
||||||
|
|
@ -298,7 +340,36 @@ mod loader {
|
||||||
manager.set_notification_handler(v4_support::notification_handler());
|
manager.set_notification_handler(v4_support::notification_handler());
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for d in &discovered {
|
for d in &discovered {
|
||||||
if !d.api_compatible() || !d.lib_present {
|
if !d.api_compatible() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !d.lib_present {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ocs_plugin_api::version_info::uses_acadrust_gate(d.api_version)
|
||||||
|
&& d.acadrust_declared
|
||||||
|
&& d.acadrust_source.is_none()
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] {} declares acadrust metadata but has no fingerprint; cannot verify compatibility",
|
||||||
|
d.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !d.acadrust_compatible() {
|
||||||
|
let host_src = ocs_plugin_api::version_info::host_acadrust_source();
|
||||||
|
let plugin_hash = d
|
||||||
|
.acadrust_source
|
||||||
|
.as_deref()
|
||||||
|
.and_then(ocs_plugin_api::version_info::acadrust_source_hash)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
out.push((
|
||||||
|
d.id.clone(),
|
||||||
|
Err(format!(
|
||||||
|
"Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}"
|
||||||
|
)),
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(path) = lib_file(&d.dir) else {
|
let Some(path) = lib_file(&d.dir) else {
|
||||||
|
|
@ -412,6 +483,121 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||||
assert!(!p.loadable());
|
assert!(!p.loadable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undeclared_acadrust_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.acadrust_declared);
|
||||||
|
assert!(p.acadrust_source.is_none());
|
||||||
|
assert!(p.acadrust_compatible(), "undeclared acadrust is treated as compatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_empty_acadrust_source_is_incompatible() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.test"
|
||||||
|
name = "Test"
|
||||||
|
version = "0.1.0"
|
||||||
|
api_version = 4
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
acadrust_source = ""
|
||||||
|
"#;
|
||||||
|
let p = parse_plugin_toml(toml).expect("parsed");
|
||||||
|
assert!(p.acadrust_declared);
|
||||||
|
assert!(p.acadrust_source.is_none());
|
||||||
|
assert!(!p.acadrust_compatible(), "declared but empty source is incompatible");
|
||||||
|
assert!(!p.loadable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acadrust_source_outside_opencad_is_ignored() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.test"
|
||||||
|
api_version = 4
|
||||||
|
acadrust_source = "0123456789012345678901234567890123456789"
|
||||||
|
"#;
|
||||||
|
let p = parse_plugin_toml(toml).expect("parsed");
|
||||||
|
assert!(!p.acadrust_declared);
|
||||||
|
assert!(p.acadrust_source.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acadrust_mismatch_detected() {
|
||||||
|
let host = ocs_plugin_api::version_info::host_acadrust_source();
|
||||||
|
let other = if host.contains("94df2c3") {
|
||||||
|
"git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"
|
||||||
|
} else {
|
||||||
|
"git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"
|
||||||
|
};
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.test"
|
||||||
|
name = "Test"
|
||||||
|
version = "0.1.0"
|
||||||
|
api_version = 4
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
acadrust_source = "{other}"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let p = parse_plugin_toml(&toml).expect("parsed");
|
||||||
|
assert!(p.acadrust_declared);
|
||||||
|
assert!(!p.acadrust_compatible(), "mismatched acadrust fingerprint should be incompatible");
|
||||||
|
assert!(!p.loadable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acadrust_match_detected() {
|
||||||
|
let host = ocs_plugin_api::version_info::host_acadrust_source();
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.test"
|
||||||
|
name = "Test"
|
||||||
|
version = "0.1.0"
|
||||||
|
api_version = 4
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
acadrust_source = "{host}"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let p = parse_plugin_toml(&toml).expect("parsed");
|
||||||
|
assert!(p.acadrust_declared);
|
||||||
|
assert!(p.acadrust_compatible(), "matching acadrust fingerprint should be compatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acadrust_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]
|
||||||
|
acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"
|
||||||
|
"#;
|
||||||
|
let mut p = parse_plugin_toml(toml).expect("parsed");
|
||||||
|
p.lib_present = true;
|
||||||
|
assert!(p.acadrust_declared);
|
||||||
|
assert!(
|
||||||
|
p.acadrust_compatible(),
|
||||||
|
"API v2 plugin should bypass acadrust gate"
|
||||||
|
);
|
||||||
|
assert!(p.loadable());
|
||||||
|
}
|
||||||
|
|
||||||
/// Integration smoke test for the out-of-process plugin path.
|
/// Integration smoke test for the out-of-process plugin path.
|
||||||
/// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the
|
/// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the
|
||||||
/// `OpenCADStudio` binary is built; the test uses it as the runner host.
|
/// `OpenCADStudio` binary is built; the test uses it as the runner host.
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,29 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
|
||||||
let manifest_text = download_string(&manifest_asset.url)?;
|
let manifest_text = download_string(&manifest_asset.url)?;
|
||||||
let manifest = external::parse_plugin_toml(&manifest_text)
|
let manifest = external::parse_plugin_toml(&manifest_text)
|
||||||
.ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?;
|
.ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?;
|
||||||
|
let acadrust_source = manifest.acadrust_source.clone();
|
||||||
|
let acadrust_declared = manifest.acadrust_declared;
|
||||||
|
let acadrust_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate(
|
||||||
|
manifest.api_version,
|
||||||
|
) {
|
||||||
|
true
|
||||||
|
} else if !acadrust_declared {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
match acadrust_source.as_deref() {
|
||||||
|
None | Some("") => false,
|
||||||
|
Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible(
|
||||||
|
source,
|
||||||
|
ocs_plugin_api::version_info::host_acadrust_source(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok::<_, String>(ReleaseInfo {
|
Ok::<_, String>(ReleaseInfo {
|
||||||
tag: release.tag,
|
tag: release.tag,
|
||||||
api_version: manifest.api_version,
|
api_version: manifest.api_version,
|
||||||
|
acadrust_source,
|
||||||
|
acadrust_declared,
|
||||||
|
acadrust_compatible,
|
||||||
})
|
})
|
||||||
})();
|
})();
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -229,6 +249,19 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
|
||||||
Err(error) => last_error = Some(error),
|
Err(error) => last_error = Some(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
for r in &mut info {
|
||||||
|
if !r.acadrust_declared
|
||||||
|
&& ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version)
|
||||||
|
{
|
||||||
|
r.acadrust_compatible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if info.is_empty() {
|
if info.is_empty() {
|
||||||
Err(last_error.unwrap_or_else(|| "no installable releases found".to_string()))
|
Err(last_error.unwrap_or_else(|| "no installable releases found".to_string()))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -283,8 +316,8 @@ fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and install a release's package into the plugins folder. Verifies
|
/// Download and install a release's package into the plugins folder. Verifies
|
||||||
/// the API version from the package's `plugin.toml` first. Returns the plugin
|
/// the API version and, when present, the `acadrust` fingerprint from the
|
||||||
/// id on success.
|
/// package's `plugin.toml` first. Returns the plugin id on success.
|
||||||
pub fn install(release: &Release, repository: &str) -> Result<String, String> {
|
pub fn install(release: &Release, repository: &str) -> Result<String, String> {
|
||||||
let lib = release.lib_asset().ok_or("no library for this platform")?;
|
let lib = release.lib_asset().ok_or("no library for this platform")?;
|
||||||
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
|
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
|
||||||
|
|
@ -300,6 +333,34 @@ pub fn install(release: &Release, repository: &str) -> Result<String, String> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version)
|
||||||
|
&& manifest.acadrust_declared
|
||||||
|
{
|
||||||
|
let Some(source) = manifest.acadrust_source.as_deref() else {
|
||||||
|
return Err(
|
||||||
|
"Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if source.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !ocs_plugin_api::version_info::acadrust_sources_compatible(
|
||||||
|
source,
|
||||||
|
ocs_plugin_api::version_info::host_acadrust_source(),
|
||||||
|
) {
|
||||||
|
let host_src = ocs_plugin_api::version_info::host_acadrust_source();
|
||||||
|
let plugin_hash = ocs_plugin_api::version_info::acadrust_source_hash(source)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
return Err(format!(
|
||||||
|
"Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dir = external::plugins_dir()
|
let dir = external::plugins_dir()
|
||||||
.ok_or("cannot locate the plugins folder")?
|
.ok_or("cannot locate the plugins folder")?
|
||||||
.join(&manifest.id);
|
.join(&manifest.id);
|
||||||
|
|
|
||||||
|
|
@ -196,17 +196,22 @@ fn trim_version_prefix(value: &str) -> &str {
|
||||||
value.trim_start_matches(|c| c == 'v' || c == 'V')
|
value.trim_start_matches(|c| c == 'v' || c == 'V')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn newest_update(installed: &str, tags: &[String]) -> Option<String> {
|
fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option<String> {
|
||||||
let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?;
|
let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?;
|
||||||
tags.iter()
|
releases
|
||||||
.filter_map(|tag| {
|
.iter()
|
||||||
semver::Version::parse(trim_version_prefix(tag))
|
.filter(|release| {
|
||||||
|
ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
|
||||||
|
&& release.acadrust_compatible
|
||||||
|
})
|
||||||
|
.filter_map(|release| {
|
||||||
|
semver::Version::parse(trim_version_prefix(&release.tag))
|
||||||
.ok()
|
.ok()
|
||||||
.map(|version| (version, tag))
|
.map(|version| (version, release.tag.clone()))
|
||||||
})
|
})
|
||||||
.filter(|(version, _)| version > &installed)
|
.filter(|(version, _)| version > &installed)
|
||||||
.max_by(|(left, _), (right, _)| left.cmp(right))
|
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||||
.map(|(_, tag)| tag.clone())
|
.map(|(_, tag)| tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn external_card<'a>(
|
fn external_card<'a>(
|
||||||
|
|
@ -219,12 +224,15 @@ fn external_card<'a>(
|
||||||
selected: bool,
|
selected: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let failed_old_api = load_error.is_some() && !p.api_compatible();
|
let failed_old_api = load_error.is_some() && !p.api_compatible();
|
||||||
|
let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible();
|
||||||
let (status, kind) = if loaded && disabled {
|
let (status, kind) = if loaded && disabled {
|
||||||
(t!("Disabled"), StatusKind::Muted)
|
(t!("Disabled"), StatusKind::Muted)
|
||||||
} else if loaded {
|
} else if loaded {
|
||||||
(t!("Loaded"), StatusKind::Success)
|
(t!("Loaded"), StatusKind::Success)
|
||||||
} else if !p.api_compatible() || failed_old_api {
|
} else if !p.api_compatible() || failed_old_api {
|
||||||
(t!("API incompatible"), StatusKind::Danger)
|
(t!("API incompatible"), StatusKind::Danger)
|
||||||
|
} else if acadrust_mismatch {
|
||||||
|
(t!("Incompatible"), StatusKind::Danger)
|
||||||
} else if load_error.is_some() {
|
} else if load_error.is_some() {
|
||||||
(t!("Load failed"), StatusKind::Danger)
|
(t!("Load failed"), StatusKind::Danger)
|
||||||
} else if !p.lib_present {
|
} else if !p.lib_present {
|
||||||
|
|
@ -352,11 +360,8 @@ fn install_controls<'a>(
|
||||||
.iter()
|
.iter()
|
||||||
.map(|release| release.tag.clone())
|
.map(|release| release.tag.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let selected_api = selected.as_ref().and_then(|selected| {
|
let selected_release = selected.as_ref().and_then(|selected| {
|
||||||
releases
|
releases.iter().find(|release| release.tag == *selected)
|
||||||
.iter()
|
|
||||||
.find(|release| release.tag == *selected)
|
|
||||||
.map(|release| release.api_version)
|
|
||||||
});
|
});
|
||||||
let picker: Element<'_, Message> = if tags.is_empty() {
|
let picker: Element<'_, Message> = if tags.is_empty() {
|
||||||
text(t!("no releases")).size(11).style(muted_style).into()
|
text(t!("no releases")).size(11).style(muted_style).into()
|
||||||
|
|
@ -367,9 +372,10 @@ fn install_controls<'a>(
|
||||||
.text_size(12)
|
.text_size(12)
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
let action = match selected_api {
|
let action = match selected_release {
|
||||||
Some(api_version)
|
Some(release)
|
||||||
if ocs_plugin_api::manifest::host_accepts_plugin_version(api_version) =>
|
if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
|
||||||
|
&& release.acadrust_compatible =>
|
||||||
{
|
{
|
||||||
pill_button(
|
pill_button(
|
||||||
t!("Install"),
|
t!("Install"),
|
||||||
|
|
@ -842,18 +848,7 @@ pub fn view_window<'a>(
|
||||||
let update_tag = repository
|
let update_tag = repository
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|repo| market.release_tags.get(repo))
|
.and_then(|repo| market.release_tags.get(repo))
|
||||||
.and_then(|releases| {
|
.and_then(|releases| newest_update(&p.version, releases));
|
||||||
let compatible_tags = releases
|
|
||||||
.iter()
|
|
||||||
.filter(|release| {
|
|
||||||
ocs_plugin_api::manifest::host_accepts_plugin_version(
|
|
||||||
release.api_version,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(|release| release.tag.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
newest_update(&p.version, &compatible_tags)
|
|
||||||
});
|
|
||||||
list = list.push(external_card(
|
list = list.push(external_card(
|
||||||
p,
|
p,
|
||||||
repository,
|
repository,
|
||||||
|
|
@ -984,16 +979,60 @@ pub fn view_web_notice<'a>() -> Element<'a, Message> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{newest_update, registry_error_message, repository_display_name};
|
use super::{newest_update, registry_error_message, repository_display_name};
|
||||||
|
use crate::plugin::external::ReleaseInfo;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn newest_update_uses_semver_not_release_order() {
|
fn newest_update_uses_semver_not_release_order() {
|
||||||
let tags = vec![
|
let releases = vec![
|
||||||
"v1.4.0".to_string(),
|
ReleaseInfo {
|
||||||
"v2.0.0".to_string(),
|
tag: "v1.4.0".to_string(),
|
||||||
"v1.9.9".to_string(),
|
api_version: 4,
|
||||||
|
acadrust_source: None,
|
||||||
|
acadrust_declared: false,
|
||||||
|
acadrust_compatible: true,
|
||||||
|
},
|
||||||
|
ReleaseInfo {
|
||||||
|
tag: "v2.0.0".to_string(),
|
||||||
|
api_version: 4,
|
||||||
|
acadrust_source: None,
|
||||||
|
acadrust_declared: false,
|
||||||
|
acadrust_compatible: true,
|
||||||
|
},
|
||||||
|
ReleaseInfo {
|
||||||
|
tag: "v1.9.9".to_string(),
|
||||||
|
api_version: 4,
|
||||||
|
acadrust_source: None,
|
||||||
|
acadrust_declared: false,
|
||||||
|
acadrust_compatible: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
assert_eq!(newest_update("1.3.0", &tags).as_deref(), Some("v2.0.0"));
|
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0"));
|
||||||
assert_eq!(newest_update("2.0.0", &tags), None);
|
assert_eq!(newest_update("2.0.0", &releases), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_update_ignores_incompatible_releases() {
|
||||||
|
let releases = vec![
|
||||||
|
ReleaseInfo {
|
||||||
|
tag: "v1.4.0".to_string(),
|
||||||
|
api_version: 4,
|
||||||
|
acadrust_source: Some(
|
||||||
|
"git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(),
|
||||||
|
),
|
||||||
|
acadrust_declared: true,
|
||||||
|
acadrust_compatible: true,
|
||||||
|
},
|
||||||
|
ReleaseInfo {
|
||||||
|
tag: "v2.0.0".to_string(),
|
||||||
|
api_version: 4,
|
||||||
|
acadrust_source: Some(
|
||||||
|
"git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(),
|
||||||
|
),
|
||||||
|
acadrust_declared: true,
|
||||||
|
acadrust_compatible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue