Skip to main content

brep_plugins/
lib.rs

1//! Trusted, explicitly provisioned packages. Saved pins never carry executable code.
2pub use brep_kernel::feature_pipeline::extension::PluginPin;
3use brep_kernel::feature_pipeline::{self as kernel, FeatureContext, FeatureResult};
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use sha2::{Digest, Sha256};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    rc::Rc,
10};
11mod annotations;
12mod invocation;
13#[derive(Clone, Debug, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase", deny_unknown_fields)]
15pub struct PackageManifest {
16    pub id: String,
17    pub version: String,
18    pub api_version: u32,
19    pub name: String,
20    pub entry: String,
21}
22#[derive(Clone, Debug, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct PackageBundle {
25    pub manifest: PackageManifest,
26    pub modules: BTreeMap<String, String>,
27    #[serde(default)]
28    pub assets: BTreeMap<String, Vec<u8>>,
29}
30fn local_path(s: &str) -> bool {
31    !s.is_empty()
32        && !s.contains('\\')
33        && !s.contains(':')
34        && s.split('/').all(|p| !p.is_empty() && p != "." && p != "..")
35}
36impl PackageBundle {
37    pub fn pin(&self) -> Result<PluginPin, String> {
38        let m = &self.manifest;
39        if m.api_version != 1
40            || m.id.is_empty()
41            || m.id.contains('/')
42            || !m
43                .id
44                .chars()
45                .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
46            || m.version.is_empty()
47            || m.name.is_empty()
48        {
49            return Err(format!(
50                "{}: invalid manifest or unsupported API version",
51                m.id
52            ));
53        }
54        if !local_path(&m.entry)
55            || !self.modules.contains_key(&m.entry)
56            || self.modules.len() > 128
57            || self
58                .modules
59                .keys()
60                .chain(self.assets.keys())
61                .any(|p| !local_path(p))
62        {
63            return Err(format!(
64                "{}: invalid package-local paths or module count",
65                m.id
66            ));
67        }
68        if self.modules.values().map(String::len).sum::<usize>()
69            + self.assets.values().map(Vec::len).sum::<usize>()
70            > 8 * 1024 * 1024
71        {
72            return Err("package exceeds 8 MiB".into());
73        }
74        let digest = format!(
75            "{:x}",
76            Sha256::digest(serde_json::to_vec(self).map_err(|e| e.to_string())?)
77        );
78        Ok(PluginPin {
79            id: m.id.clone(),
80            version: m.version.clone(),
81            api_version: m.api_version,
82            digest,
83        })
84    }
85}
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct FeatureRegistration {
89    pub id: String,
90    pub label: String,
91    pub glyph: String,
92    pub ribbon_path: String,
93    pub command_size: String,
94    pub short_name: String,
95    pub input_params_schema: Value,
96    pub package: PluginPin,
97}
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct ActionRegistration {
101    pub id: String,
102    pub label: String,
103    pub glyph: String,
104    pub ribbon_path: String,
105    pub command_size: String,
106    pub input_params_schema: Value,
107    pub package: PluginPin,
108}
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct WorkbenchRegistration {
112    pub id: String,
113    pub label: String,
114    pub feature_types: Vec<String>,
115    pub actions: Vec<String>,
116    pub panels: Vec<String>,
117    pub package: PluginPin,
118}
119#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct AnnotationRegistration {
122    pub id: String,
123    pub label: String,
124    pub input_params_schema: Value,
125    pub package: PluginPin,
126}
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct PanelRegistration {
130    pub id: String,
131    pub label: String,
132    pub controls: Vec<PanelControl>,
133    pub package: PluginPin,
134}
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
137pub enum PanelControl {
138    Text {
139        text: String,
140    },
141    Table {
142        columns: Vec<String>,
143        rows: Vec<Vec<String>>,
144    },
145    Action {
146        id: String,
147        label: String,
148        action: String,
149        #[serde(default = "empty_object")]
150        params: Value,
151    },
152    Form {
153        id: String,
154        label: String,
155        action: String,
156        #[serde(default = "empty_object")]
157        params: Value,
158    },
159}
160fn empty_object() -> Value {
161    json!({})
162}
163#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
164pub struct Registry {
165    features: Vec<FeatureRegistration>,
166    actions: Vec<ActionRegistration>,
167    workbenches: Vec<WorkbenchRegistration>,
168    #[serde(default)]
169    panels: Vec<PanelRegistration>,
170    #[serde(default)]
171    annotations: Vec<AnnotationRegistration>,
172    packages: Vec<PluginPin>,
173    revision: String,
174}
175impl Registry {
176    pub fn panels(&self) -> &[PanelRegistration] {
177        &self.panels
178    }
179    pub fn annotations(&self) -> &[AnnotationRegistration] {
180        &self.annotations
181    }
182    pub fn features(&self) -> &[FeatureRegistration] {
183        &self.features
184    }
185    pub fn actions(&self) -> &[ActionRegistration] {
186        &self.actions
187    }
188    pub fn workbenches(&self) -> &[WorkbenchRegistration] {
189        &self.workbenches
190    }
191    pub fn revision(&self) -> &str {
192        &self.revision
193    }
194    pub fn install(packages: &[PackageBundle]) -> Result<Self, String> {
195        let mut result = Self::default();
196        let mut seen = BTreeSet::new();
197        for package in packages {
198            let pin = package.pin()?;
199            if !seen.insert((pin.id.clone(), pin.digest.clone())) {
200                return Err(format!("duplicate package {}", pin.id));
201            }
202            let r = invocation::invoke(package, None, None, None, None)?.0;
203            result.packages.push(pin);
204            result.features.extend(r.features);
205            result.actions.extend(r.actions);
206            result.workbenches.extend(r.workbenches);
207            result.panels.extend(r.panels);
208            result.annotations.extend(r.annotations);
209        }
210        result.revision = format!("{:x}", Sha256::digest(serde_json::to_vec(&result).unwrap()));
211        Ok(result)
212    }
213    pub fn for_document(&self, pins: &[PluginPin]) -> Result<Self, String> {
214        let mut ids = BTreeSet::new();
215        for pin in pins {
216            if !ids.insert(&pin.id) {
217                return Err(format!("duplicate document pin {}", pin.id));
218            }
219            if !self.packages.contains(pin) {
220                return Err(format!("missing or disabled pinned package {}", pin.id));
221            }
222        }
223        let mut r = self.clone();
224        r.packages = pins.to_vec();
225        r.features.retain(|x| pins.contains(&x.package));
226        r.actions.retain(|x| pins.contains(&x.package));
227        r.workbenches.retain(|x| pins.contains(&x.package));
228        r.panels.retain(|x| pins.contains(&x.package));
229        r.annotations.retain(|x| pins.contains(&x.package));
230        r.revision = format!("{:x}", Sha256::digest(serde_json::to_vec(&r).unwrap()));
231        Ok(r)
232    }
233}
234#[derive(Clone, Debug, Serialize, Deserialize)]
235#[serde(tag = "op", rename_all = "camelCase")]
236pub enum DocumentCommand {
237    AddFeature {
238        #[serde(rename = "featureType")]
239        feature_type: String,
240        #[serde(rename = "inputParams")]
241        input_params: Value,
242        #[serde(rename = "persistentData")]
243        persistent_data: Value,
244    },
245    UpdateFeature {
246        id: String,
247        #[serde(rename = "inputParams")]
248        input_params: Value,
249    },
250    DeleteFeature {
251        id: String,
252    },
253}
254#[derive(Clone, Debug, Default, Serialize, Deserialize)]
255pub struct ActionPlan {
256    pub commands: Vec<DocumentCommand>,
257    pub notifications: Vec<String>,
258}
259
260/// Execute an explicitly submitted editor script without installing or pinning it.
261/// The source is a function body with `cad` (the action context) and `ui.notify`.
262/// Only the resulting document commands survive; built-in features replay normally.
263pub fn execute_script(source: &str, input: &Value) -> Result<ActionPlan, String> {
264    let package = PackageBundle {
265        manifest: PackageManifest {
266            id: "cad.editor".into(), version: "1".into(), api_version: 1,
267            name: "JavaScript editor".into(), entry: "editor.js".into(),
268        },
269        modules: BTreeMap::from([("editor.js".into(), format!(r#"
270export default function install(app) {{
271    app.registerAction({{id: 'cad.editor/run', label: 'Run script', run(cad) {{
272        const ui = Object.freeze({{notify: cad.notify}});
273        const result = (function(cad, ui) {{
274{source}
275        }})(cad, ui);
276        if (result && typeof result.then === 'function') throw Error('Promise results are unsupported');
277        if (result !== undefined) console.log('Result:', JSON.stringify(result));
278    }} }});
279}}
280"#))]),
281        assets: BTreeMap::new(),
282    };
283    let (_, value, _) = invocation::invoke(
284        &package, Some(("action", "cad.editor/run")), Some(input), None, None,
285    )?;
286    serde_json::from_value(value).map_err(|e| e.to_string())
287}
288#[derive(Clone)]
289pub struct Runtime {
290    packages: Vec<PackageBundle>,
291    registry: Registry,
292}
293impl Runtime {
294    pub fn new(packages: Vec<PackageBundle>) -> Result<Self, String> {
295        let registry = Registry::install(&packages)?;
296        Ok(Self { packages, registry })
297    }
298    /// Rehydrate a registry validated by a trusted worker. This is not an install API
299    /// for untrusted metadata. Hash/owner checks do not execute JavaScript.
300    pub fn from_validated(
301        packages: Vec<PackageBundle>,
302        registry: Registry,
303    ) -> Result<Self, String> {
304        let pins = packages
305            .iter()
306            .map(PackageBundle::pin)
307            .collect::<Result<Vec<_>, _>>()?;
308        if pins != registry.packages {
309            return Err("validated registry package pins mismatch".into());
310        }
311        for (id, pin) in registry
312            .features
313            .iter()
314            .map(|r| (&r.id, &r.package))
315            .chain(registry.actions.iter().map(|r| (&r.id, &r.package)))
316            .chain(registry.workbenches.iter().map(|r| (&r.id, &r.package)))
317            .chain(registry.panels.iter().map(|r| (&r.id, &r.package)))
318            .chain(registry.annotations.iter().map(|r| (&r.id, &r.package)))
319        {
320            if !pins.contains(pin) || !id.starts_with(&format!("{}/", pin.id)) {
321                return Err("validated registry ownership mismatch".into());
322            }
323        }
324        Ok(Self { packages, registry })
325    }
326    pub fn registry(&self) -> &Registry {
327        &self.registry
328    }
329    pub fn with_provider<R>(&self, f: impl FnOnce() -> R) -> R {
330        kernel::extension::with_provider(Rc::new(self.clone()), f)
331    }
332    pub fn execute_history(&self, request: &kernel::HistoryRequest) -> kernel::HistoryResult {
333        self.with_provider(|| kernel::execute_history(request))
334    }
335    fn package(&self, pins: &[PluginPin], id: &str) -> Result<&PackageBundle, String> {
336        let owner = id.split('/').next().unwrap_or("");
337        let matches: Vec<_> = pins.iter().filter(|p| p.id == owner).collect();
338        if matches.len() != 1 {
339            return Err(format!("{} requires exactly one document plugin pin", id));
340        }
341        self.packages
342            .iter()
343            .find(|p| p.pin().as_ref() == Ok(matches[0]))
344            .ok_or_else(|| format!("missing or disabled pinned package for {id}"))
345    }
346    pub fn execute_action(
347        &self,
348        pins: &[PluginPin],
349        id: &str,
350        input: &Value,
351    ) -> Result<ActionPlan, String> {
352        let p = self.package(pins, id)?;
353        let (r, v, _) = invocation::invoke(
354            p,
355            Some(("action", id)),
356            Some(input),
357            None,
358            Some(&self.registry.for_document(&[p.pin()?])?),
359        )?;
360        self.verify(p, &r)?;
361        serde_json::from_value(v).map_err(|e| e.to_string())
362    }
363    fn verify(&self, p: &PackageBundle, r: &Registry) -> Result<(), String> {
364        let expected = self.registry.for_document(&[p.pin()?])?;
365        if expected.features != r.features
366            || expected.actions != r.actions
367            || expected.workbenches != r.workbenches
368            || expected.panels != r.panels
369            || expected.annotations != r.annotations
370        {
371            return Err("registration metadata changed during invocation".into());
372        }
373        Ok(())
374    }
375}
376impl kernel::extension::ExtensionProvider for Runtime {
377    fn execute_annotation(
378        &self,
379        pins: &[PluginPin],
380        annotation: &kernel::pmi::PmiAnnotation,
381        context: &kernel::pmi::PmiContext<'_>,
382    ) -> Result<kernel::pmi::PluginAnnotationOutput, String> {
383        Runtime::execute_annotation(self, pins, annotation, context)
384    }
385    fn identity(&self, pins: &[PluginPin], kind: &str) -> Result<String, String> {
386        let p = self.package(pins, kind)?;
387        if !self
388            .registry
389            .features
390            .iter()
391            .any(|r| r.id == kind && r.package == p.pin().unwrap())
392        {
393            return Err(format!("unregistered plugin feature {kind}"));
394        }
395        Ok(format!("{}:{}:1", p.pin()?.digest, kind))
396    }
397    fn execute(&self, pins: &[PluginPin], ctx: &FeatureContext<'_>) -> FeatureResult {
398        let run = || -> Result<FeatureResult, String> {
399            let p = self.package(pins, &ctx.feature_type)?;
400            let reg = self
401                .registry
402                .features
403                .iter()
404                .find(|r| r.id == ctx.feature_type && r.package == p.pin().unwrap())
405                .ok_or("feature registration unavailable")?;
406            let mut params = ctx.params.clone();
407            validate_params(&reg.input_params_schema, &mut params, Some(ctx))?;
408            let input = json!({"params":params,"persistentData":ctx.persistent,"featureId":ctx.id,"units":"mm"});
409            let (r, _, result) = invocation::invoke(
410                p,
411                Some(("feature", &ctx.feature_type)),
412                Some(&input),
413                Some(ctx),
414                Some(&self.registry.for_document(&[p.pin()?])?),
415            )?;
416            self.verify(p, &r)?;
417            // Invocation publishes only after metadata, return and all handles validate.
418            result.ok_or_else(|| "missing feature result".into())
419        };
420        match run() {
421            Ok(r) => r,
422            Err(e) => ctx.fail(e),
423        }
424    }
425}
426fn validate_params(
427    schema: &Value,
428    params: &mut Value,
429    ctx: Option<&FeatureContext<'_>>,
430) -> Result<(), String> {
431    let obj = params
432        .as_object_mut()
433        .ok_or("parameters must be an object")?;
434    let fields = schema.as_object().ok_or("schema must be object")?;
435    for key in obj.keys() {
436        if key != "id" && !fields.contains_key(key) {
437            return Err(format!("unknown parameter {key}"));
438        }
439    }
440    if obj.get("id").is_some_and(|v| !v.is_string()) {
441        return Err("feature id must be string".into());
442    }
443    for (key, s) in schema.as_object().ok_or("schema must be object")? {
444        if !obj.contains_key(key) {
445            if let Some(v) = s.get("default_value") {
446                obj.insert(key.clone(), v.clone());
447            }
448        }
449        let v = obj
450            .get_mut(key)
451            .ok_or_else(|| format!("missing parameter {key}"))?;
452        match s["type"].as_str().unwrap_or("") {
453            "number" => {
454                let n = if let Some(n) = v.as_f64() {
455                    n
456                } else if let (Some(c), Some(text)) = (ctx, v.as_str()) {
457                    c.env.eval(text)?
458                } else {
459                    return Err(format!("{key} must be numeric"));
460                };
461                if !n.is_finite()
462                    || s["min"].as_f64().is_some_and(|m| n < m)
463                    || s["max"].as_f64().is_some_and(|m| n > m)
464                {
465                    return Err(format!("{key} outside finite numeric bounds"));
466                }
467                *v = json!(n);
468            }
469            "reference_selection" => {
470                fn reference(v: &Value) -> bool {
471                    v.is_string()
472                        || v.as_object()
473                            .is_some_and(|o| o.get("name").is_some_and(Value::is_string))
474                }
475                if !reference(v) && !v.as_array().is_some_and(|a| a.iter().all(reference)) {
476                    return Err(format!("{key} must contain reference names"));
477                }
478            }
479            "string" if !v.is_string() => return Err(format!("{key} must be string")),
480            "boolean" if !v.is_boolean() => return Err(format!("{key} must be boolean")),
481            _ => {}
482        }
483    }
484    Ok(())
485}