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/// A CAM post-processor a package ships: `module` names the package-local
128/// script (a `post.define({...})` file for BREP_cam's post host). The package
129/// runtime only registers it; the CAM workbench reads the module text and
130/// runs it on BREP_cam's post runtime.
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct PostRegistration {
134    pub id: String,
135    pub label: String,
136    pub module: String,
137    pub package: PluginPin,
138}
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct PanelRegistration {
142    pub id: String,
143    pub label: String,
144    pub controls: Vec<PanelControl>,
145    pub package: PluginPin,
146}
147#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
148#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
149pub enum PanelControl {
150    Text {
151        text: String,
152    },
153    Table {
154        columns: Vec<String>,
155        rows: Vec<Vec<String>>,
156    },
157    Action {
158        id: String,
159        label: String,
160        action: String,
161        #[serde(default = "empty_object")]
162        params: Value,
163    },
164    Form {
165        id: String,
166        label: String,
167        action: String,
168        #[serde(default = "empty_object")]
169        params: Value,
170    },
171}
172fn empty_object() -> Value {
173    json!({})
174}
175#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
176pub struct Registry {
177    features: Vec<FeatureRegistration>,
178    actions: Vec<ActionRegistration>,
179    workbenches: Vec<WorkbenchRegistration>,
180    #[serde(default)]
181    panels: Vec<PanelRegistration>,
182    #[serde(default)]
183    annotations: Vec<AnnotationRegistration>,
184    #[serde(default)]
185    posts: Vec<PostRegistration>,
186    packages: Vec<PluginPin>,
187    revision: String,
188}
189impl Registry {
190    pub fn panels(&self) -> &[PanelRegistration] {
191        &self.panels
192    }
193    pub fn posts(&self) -> &[PostRegistration] {
194        &self.posts
195    }
196    pub fn annotations(&self) -> &[AnnotationRegistration] {
197        &self.annotations
198    }
199    pub fn features(&self) -> &[FeatureRegistration] {
200        &self.features
201    }
202    pub fn actions(&self) -> &[ActionRegistration] {
203        &self.actions
204    }
205    pub fn workbenches(&self) -> &[WorkbenchRegistration] {
206        &self.workbenches
207    }
208    pub fn revision(&self) -> &str {
209        &self.revision
210    }
211    pub fn install(packages: &[PackageBundle]) -> Result<Self, String> {
212        let mut result = Self::default();
213        let mut seen = BTreeSet::new();
214        for package in packages {
215            let pin = package.pin()?;
216            if !seen.insert((pin.id.clone(), pin.digest.clone())) {
217                return Err(format!("duplicate package {}", pin.id));
218            }
219            let r = invocation::invoke(package, None, None, None, None)?.0;
220            result.packages.push(pin);
221            result.features.extend(r.features);
222            result.actions.extend(r.actions);
223            result.workbenches.extend(r.workbenches);
224            result.panels.extend(r.panels);
225            result.annotations.extend(r.annotations);
226            result.posts.extend(r.posts);
227        }
228        result.revision = format!("{:x}", Sha256::digest(serde_json::to_vec(&result).unwrap()));
229        Ok(result)
230    }
231    pub fn for_document(&self, pins: &[PluginPin]) -> Result<Self, String> {
232        let mut ids = BTreeSet::new();
233        for pin in pins {
234            if !ids.insert(&pin.id) {
235                return Err(format!("duplicate document pin {}", pin.id));
236            }
237            if !self.packages.contains(pin) {
238                return Err(format!("missing or disabled pinned package {}", pin.id));
239            }
240        }
241        let mut r = self.clone();
242        r.packages = pins.to_vec();
243        r.features.retain(|x| pins.contains(&x.package));
244        r.actions.retain(|x| pins.contains(&x.package));
245        r.workbenches.retain(|x| pins.contains(&x.package));
246        r.panels.retain(|x| pins.contains(&x.package));
247        r.annotations.retain(|x| pins.contains(&x.package));
248        r.posts.retain(|x| pins.contains(&x.package));
249        r.revision = format!("{:x}", Sha256::digest(serde_json::to_vec(&r).unwrap()));
250        Ok(r)
251    }
252}
253#[derive(Clone, Debug, Serialize, Deserialize)]
254#[serde(tag = "op", rename_all = "camelCase")]
255pub enum DocumentCommand {
256    AddFeature {
257        #[serde(rename = "featureType")]
258        feature_type: String,
259        #[serde(rename = "inputParams")]
260        input_params: Value,
261        #[serde(rename = "persistentData")]
262        persistent_data: Value,
263    },
264    UpdateFeature {
265        id: String,
266        #[serde(rename = "inputParams")]
267        input_params: Value,
268    },
269    DeleteFeature {
270        id: String,
271    },
272}
273#[derive(Clone, Debug, Default, Serialize, Deserialize)]
274pub struct ActionPlan {
275    pub commands: Vec<DocumentCommand>,
276    pub notifications: Vec<String>,
277}
278
279/// Execute an explicitly submitted editor script without installing or pinning it.
280/// The source is a function body with `cad` (the action context) and `ui.notify`.
281/// Only the resulting document commands survive; built-in features replay normally.
282pub fn execute_script(source: &str, input: &Value) -> Result<ActionPlan, String> {
283    let package = PackageBundle {
284        manifest: PackageManifest {
285            id: "cad.editor".into(), version: "1".into(), api_version: 1,
286            name: "JavaScript editor".into(), entry: "editor.js".into(),
287        },
288        modules: BTreeMap::from([("editor.js".into(), format!(r#"
289export default function install(app) {{
290    app.registerAction({{id: 'cad.editor/run', label: 'Run script', run(cad) {{
291        const ui = Object.freeze({{notify: cad.notify}});
292        const result = (function(cad, ui) {{
293{source}
294        }})(cad, ui);
295        if (result && typeof result.then === 'function') throw Error('Promise results are unsupported');
296        if (result !== undefined) console.log('Result:', JSON.stringify(result));
297    }} }});
298}}
299"#))]),
300        assets: BTreeMap::new(),
301    };
302    let (_, value, _) = invocation::invoke(
303        &package, Some(("action", "cad.editor/run")), Some(input), None, None,
304    )?;
305    serde_json::from_value(value).map_err(|e| e.to_string())
306}
307#[derive(Clone)]
308pub struct Runtime {
309    packages: Vec<PackageBundle>,
310    registry: Registry,
311}
312impl Runtime {
313    pub fn new(packages: Vec<PackageBundle>) -> Result<Self, String> {
314        let registry = Registry::install(&packages)?;
315        Ok(Self { packages, registry })
316    }
317    /// Rehydrate a registry validated by a trusted worker. This is not an install API
318    /// for untrusted metadata. Hash/owner checks do not execute JavaScript.
319    pub fn from_validated(
320        packages: Vec<PackageBundle>,
321        registry: Registry,
322    ) -> Result<Self, String> {
323        let pins = packages
324            .iter()
325            .map(PackageBundle::pin)
326            .collect::<Result<Vec<_>, _>>()?;
327        if pins != registry.packages {
328            return Err("validated registry package pins mismatch".into());
329        }
330        for (id, pin) in registry
331            .features
332            .iter()
333            .map(|r| (&r.id, &r.package))
334            .chain(registry.actions.iter().map(|r| (&r.id, &r.package)))
335            .chain(registry.workbenches.iter().map(|r| (&r.id, &r.package)))
336            .chain(registry.panels.iter().map(|r| (&r.id, &r.package)))
337            .chain(registry.annotations.iter().map(|r| (&r.id, &r.package)))
338            .chain(registry.posts.iter().map(|r| (&r.id, &r.package)))
339        {
340            if !pins.contains(pin) || !id.starts_with(&format!("{}/", pin.id)) {
341                return Err("validated registry ownership mismatch".into());
342            }
343        }
344        Ok(Self { packages, registry })
345    }
346    pub fn registry(&self) -> &Registry {
347        &self.registry
348    }
349    pub fn with_provider<R>(&self, f: impl FnOnce() -> R) -> R {
350        kernel::extension::with_provider(Rc::new(self.clone()), f)
351    }
352    pub fn execute_history(&self, request: &kernel::HistoryRequest) -> kernel::HistoryResult {
353        self.with_provider(|| kernel::execute_history(request))
354    }
355    fn package(&self, pins: &[PluginPin], id: &str) -> Result<&PackageBundle, String> {
356        let owner = id.split('/').next().unwrap_or("");
357        let matches: Vec<_> = pins.iter().filter(|p| p.id == owner).collect();
358        if matches.len() != 1 {
359            return Err(format!("{} requires exactly one document plugin pin", id));
360        }
361        self.packages
362            .iter()
363            .find(|p| p.pin().as_ref() == Ok(matches[0]))
364            .ok_or_else(|| format!("missing or disabled pinned package for {id}"))
365    }
366    pub fn execute_action(
367        &self,
368        pins: &[PluginPin],
369        id: &str,
370        input: &Value,
371    ) -> Result<ActionPlan, String> {
372        let p = self.package(pins, id)?;
373        let (r, v, _) = invocation::invoke(
374            p,
375            Some(("action", id)),
376            Some(input),
377            None,
378            Some(&self.registry.for_document(&[p.pin()?])?),
379        )?;
380        self.verify(p, &r)?;
381        serde_json::from_value(v).map_err(|e| e.to_string())
382    }
383    fn verify(&self, p: &PackageBundle, r: &Registry) -> Result<(), String> {
384        let expected = self.registry.for_document(&[p.pin()?])?;
385        if expected.features != r.features
386            || expected.actions != r.actions
387            || expected.workbenches != r.workbenches
388            || expected.panels != r.panels
389            || expected.annotations != r.annotations
390        {
391            return Err("registration metadata changed during invocation".into());
392        }
393        Ok(())
394    }
395}
396impl kernel::extension::ExtensionProvider for Runtime {
397    fn execute_annotation(
398        &self,
399        pins: &[PluginPin],
400        annotation: &kernel::pmi::PmiAnnotation,
401        context: &kernel::pmi::PmiContext<'_>,
402    ) -> Result<kernel::pmi::PluginAnnotationOutput, String> {
403        Runtime::execute_annotation(self, pins, annotation, context)
404    }
405    fn identity(&self, pins: &[PluginPin], kind: &str) -> Result<String, String> {
406        let p = self.package(pins, kind)?;
407        if !self
408            .registry
409            .features
410            .iter()
411            .any(|r| r.id == kind && r.package == p.pin().unwrap())
412        {
413            return Err(format!("unregistered plugin feature {kind}"));
414        }
415        Ok(format!("{}:{}:1", p.pin()?.digest, kind))
416    }
417    fn execute(&self, pins: &[PluginPin], ctx: &FeatureContext<'_>) -> FeatureResult {
418        let run = || -> Result<FeatureResult, String> {
419            let p = self.package(pins, &ctx.feature_type)?;
420            let reg = self
421                .registry
422                .features
423                .iter()
424                .find(|r| r.id == ctx.feature_type && r.package == p.pin().unwrap())
425                .ok_or("feature registration unavailable")?;
426            let mut params = ctx.params.clone();
427            validate_params(&reg.input_params_schema, &mut params, Some(ctx))?;
428            let input = json!({"params":params,"persistentData":ctx.persistent,"featureId":ctx.id,"units":"mm"});
429            let (r, _, result) = invocation::invoke(
430                p,
431                Some(("feature", &ctx.feature_type)),
432                Some(&input),
433                Some(ctx),
434                Some(&self.registry.for_document(&[p.pin()?])?),
435            )?;
436            self.verify(p, &r)?;
437            // Invocation publishes only after metadata, return and all handles validate.
438            result.ok_or_else(|| "missing feature result".into())
439        };
440        match run() {
441            Ok(r) => r,
442            Err(e) => ctx.fail(e),
443        }
444    }
445}
446fn validate_params(
447    schema: &Value,
448    params: &mut Value,
449    ctx: Option<&FeatureContext<'_>>,
450) -> Result<(), String> {
451    let obj = params
452        .as_object_mut()
453        .ok_or("parameters must be an object")?;
454    let fields = schema.as_object().ok_or("schema must be object")?;
455    for key in obj.keys() {
456        if key != "id" && !fields.contains_key(key) {
457            return Err(format!("unknown parameter {key}"));
458        }
459    }
460    if obj.get("id").is_some_and(|v| !v.is_string()) {
461        return Err("feature id must be string".into());
462    }
463    for (key, s) in schema.as_object().ok_or("schema must be object")? {
464        if !obj.contains_key(key) {
465            if let Some(v) = s.get("default_value") {
466                obj.insert(key.clone(), v.clone());
467            }
468        }
469        let v = obj
470            .get_mut(key)
471            .ok_or_else(|| format!("missing parameter {key}"))?;
472        match s["type"].as_str().unwrap_or("") {
473            "number" => {
474                let n = if let Some(n) = v.as_f64() {
475                    n
476                } else if let (Some(c), Some(text)) = (ctx, v.as_str()) {
477                    c.env.eval(text)?
478                } else {
479                    return Err(format!("{key} must be numeric"));
480                };
481                if !n.is_finite()
482                    || s["min"].as_f64().is_some_and(|m| n < m)
483                    || s["max"].as_f64().is_some_and(|m| n > m)
484                {
485                    return Err(format!("{key} outside finite numeric bounds"));
486                }
487                *v = json!(n);
488            }
489            "reference_selection" => {
490                fn reference(v: &Value) -> bool {
491                    v.is_string()
492                        || v.as_object()
493                            .is_some_and(|o| o.get("name").is_some_and(Value::is_string))
494                }
495                if !reference(v) && !v.as_array().is_some_and(|a| a.iter().all(reference)) {
496                    return Err(format!("{key} must contain reference names"));
497                }
498            }
499            "string" if !v.is_string() => return Err(format!("{key} must be string")),
500            "boolean" if !v.is_boolean() => return Err(format!("{key} must be boolean")),
501            _ => {}
502        }
503    }
504    Ok(())
505}