Skip to main content

uni_plugin_rhai/
loader.rs

1//! Rhai loader — three-phase load mirroring `ExtismLoader::load`.
2//!
3//! Phase 1: build a sandboxed engine, compile the script, read
4//!           `uni_manifest()` to discover declared capabilities and
5//!           function entries.
6//! Phase 2: intersect declared capabilities with host grants → effective
7//!           set; rebuild the engine with capability-gated host fns
8//!           registered for the effective set.
9//! Phase 3: register each manifest entry on the supplied
10//!           `PluginRegistrar` as a `ScalarPluginFn` / `AggregatePluginFn` /
11//!           `ProcedurePlugin` adapter. The caller commits the registrar
12//!           atomically to the registry.
13
14#![cfg(feature = "rhai-runtime")]
15
16use std::sync::Arc;
17
18use uni_plugin::{
19    Capability, CapabilitySet, HttpEgress, KmsProvider, PluginError, PluginId, PluginRegistrar,
20    QName,
21};
22
23use arrow_schema::Field;
24
25use uni_plugin::capability::SideEffects;
26use uni_plugin::secrets::SecretStore;
27use uni_plugin::traits::procedure::{NamedArgType, ProcedureMode, ProcedureSignature};
28
29use crate::adapter::RhaiScalarFn;
30use crate::adapter_aggregate::{RhaiAggregateFn, build_agg_signature};
31use crate::adapter_procedure::RhaiProcedure;
32use crate::engine::build_engine;
33use crate::error::RhaiError;
34use crate::host_fns::RhaiHostFnRegistry;
35use crate::manifest::{ProcedureEntry, RhaiManifest, compile, parse_manifest};
36use crate::runtime::RhaiPluginRuntime;
37use crate::wire_translate::{build_fn_signature, type_name_to_argtype, type_name_to_datatype};
38
39/// Outcome of a successful Rhai plugin load.
40#[derive(Debug)]
41pub struct LoadOutcome {
42    /// Plugin id as declared in `uni_manifest()`.
43    pub plugin_id: PluginId,
44    /// Plugin version string (semver).
45    pub version: String,
46    /// Capabilities that were both declared by the plugin and granted by
47    /// the host (the intersection).
48    pub effective_capabilities: CapabilitySet,
49    /// Capabilities the plugin declared but the host did not grant.
50    pub denied_capabilities: Vec<Capability>,
51    /// Fully-qualified names of scalar fns the loader registered.
52    pub scalars_registered: Vec<String>,
53    /// Aggregate qnames registered.
54    pub aggregates_registered: Vec<String>,
55    /// Procedure qnames registered.
56    pub procedures_registered: Vec<String>,
57    /// Strong reference to the per-plugin runtime. Adapters hold inner
58    /// `Arc` clones; the host can drop this on unload to release the
59    /// engine.
60    pub runtime: Arc<RhaiPluginRuntime>,
61}
62
63/// Rhai loader.
64///
65/// Holds the host-fn registry; one loader can serve many plugins. Cheap
66/// to clone (host fns are `Arc`'d closures).
67#[derive(Default, Clone)]
68pub struct RhaiLoader {
69    host_fns: RhaiHostFnRegistry,
70    /// Optional KMS provider backing `uni.kms.*`. Absent → those fns error
71    /// loudly at call time ("no KMS provider configured").
72    kms: Option<Arc<dyn KmsProvider>>,
73    /// Optional secret store backing `uni.secret.acquire`.
74    secrets: Option<Arc<SecretStore>>,
75    /// Optional HTTP egress backing `uni.http.*`.
76    http: Option<Arc<dyn HttpEgress>>,
77}
78
79impl std::fmt::Debug for RhaiLoader {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("RhaiLoader")
82            .field("host_fn_count", &self.host_fns.len())
83            .finish()
84    }
85}
86
87impl RhaiLoader {
88    /// Construct an empty loader (no host fns yet).
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Mutable access to the host-fn registry for the host to register
95    /// its capability-gated functions before any plugin is loaded.
96    pub fn host_fns_mut(&mut self) -> &mut RhaiHostFnRegistry {
97        &mut self.host_fns
98    }
99
100    /// Read access to the host-fn registry.
101    #[must_use]
102    pub fn host_fns(&self) -> &RhaiHostFnRegistry {
103        &self.host_fns
104    }
105
106    /// Number of host fns currently registered.
107    #[must_use]
108    pub fn host_fn_count(&self) -> usize {
109        self.host_fns.len()
110    }
111
112    /// Attach a KMS provider backing `uni.kms.*` (builder style).
113    #[must_use]
114    pub fn with_kms(mut self, kms: Arc<dyn KmsProvider>) -> Self {
115        self.kms = Some(kms);
116        self
117    }
118
119    /// Attach a secret store backing `uni.secret.acquire` (builder style).
120    #[must_use]
121    pub fn with_secret_store(mut self, store: Arc<SecretStore>) -> Self {
122        self.secrets = Some(store);
123        self
124    }
125
126    /// Attach an HTTP egress backing `uni.http.*` (builder style).
127    #[must_use]
128    pub fn with_http(mut self, http: Arc<dyn HttpEgress>) -> Self {
129        self.http = Some(http);
130        self
131    }
132
133    /// Clone of the configured KMS provider handle, if any.
134    #[must_use]
135    pub fn kms(&self) -> Option<Arc<dyn KmsProvider>> {
136        self.kms.clone()
137    }
138
139    /// Clone of the configured secret store handle, if any.
140    #[must_use]
141    pub fn secret_store(&self) -> Option<Arc<SecretStore>> {
142        self.secrets.clone()
143    }
144
145    /// Clone of the configured HTTP egress handle, if any.
146    #[must_use]
147    pub fn http(&self) -> Option<Arc<dyn HttpEgress>> {
148        self.http.clone()
149    }
150
151    /// Load a Rhai script into a `PluginRegistrar`.
152    ///
153    /// The caller is responsible for calling
154    /// `registrar.commit_to_registry()` on success.
155    ///
156    /// `registrar_caps` is the **host grant set** — what capabilities
157    /// the host is willing to give this plugin. The effective set is
158    /// the intersection of `registrar_caps` and the manifest's
159    /// declared capability set. Granted-but-not-declared capabilities
160    /// are silently ignored (least-authority); declared-but-not-granted
161    /// are surfaced as `denied_capabilities`.
162    pub fn load(
163        &self,
164        script: &str,
165        registrar: &mut PluginRegistrar<'_>,
166        registrar_caps: &CapabilitySet,
167    ) -> Result<LoadOutcome, RhaiError> {
168        // Phase 1: build an engine with the host's grant set so that
169        // scripts referring to capability-gated host fns parse-resolve
170        // during manifest extraction. The manifest call doesn't invoke
171        // host fns, but the script may reference them in other fns and
172        // Rhai resolves all function calls at parse time.
173        //
174        // Host-fn registration is gated by the host's GRANT set
175        // (`registrar_caps`), not by the manifest's declared capabilities —
176        // host fns like `uni.fs.read` aren't enumerated in the manifest, but
177        // the plugin can still call them if and only if the host granted the
178        // underlying capability. Extension-surface caps (ScalarFn etc.) are
179        // gated separately at registration time via the `effective` set.
180        //
181        // The same engine + AST become the runtime artifacts: phase 2's real
182        // engine would be built from the identical `(registrar_caps,
183        // host_fns)` inputs and the identical script, and `parse_manifest`
184        // only *calls* `uni_manifest()` against the AST (it never mutates it),
185        // so a second build+compile would be byte-for-byte redundant.
186        let engine = build_engine(registrar_caps, &self.host_fns);
187        let ast = compile(&engine, script)?;
188        let manifest = parse_manifest(&engine, &ast)?;
189
190        let plugin_id = PluginId::new(manifest.id.clone());
191
192        // Phase 2: declared capabilities for this plugin. v1 derives the
193        // declared set from the function-kind entries — every script
194        // implicitly declares `ScalarFn` / `AggregateFn` / `Procedure`
195        // for each entry it provides. Future: an explicit
196        // `capabilities:` field in the manifest can request specific
197        // host-fn caps (Filesystem, Network, etc).
198        let declared = derive_declared_capabilities(&manifest);
199        let (effective, denied) = intersect_caps(&declared, registrar_caps);
200
201        let runtime = RhaiPluginRuntime::new(plugin_id.clone(), engine, ast);
202
203        // Phase 3: register entries.
204        registrar.set_plugin_id(plugin_id.clone());
205
206        // Per proposal §10.2 / §M7: only register entries whose
207        // declared capability is in the effective set. Entries whose
208        // capability was denied surface via `denied_capabilities` so
209        // operators can see what was dropped.
210        let mut scalars_registered = Vec::with_capacity(manifest.scalar_fns.len());
211        if effective.contains(&Capability::ScalarFn) {
212            for entry in &manifest.scalar_fns {
213                let sig = build_fn_signature(&entry.args, &entry.returns, &manifest.determinism)?;
214                let qname = QName::new(plugin_id.as_str(), entry.name.clone());
215                let adapter = if entry.vectorized {
216                    RhaiScalarFn::new_vectorized(
217                        Arc::clone(&runtime),
218                        entry.name.clone(),
219                        sig.clone(),
220                    )
221                } else {
222                    RhaiScalarFn::new(Arc::clone(&runtime), entry.name.clone(), sig.clone())
223                };
224                registrar
225                    .scalar_fn(qname.clone(), sig, Arc::new(adapter))
226                    .map_err(plugin_to_rhai_err)?;
227                scalars_registered.push(qname.to_string());
228            }
229        }
230
231        let mut aggregates_registered = Vec::with_capacity(manifest.aggregate_fns.len());
232        if effective.contains(&Capability::AggregateFn) {
233            for entry in &manifest.aggregate_fns {
234                let sig = build_agg_signature(&entry.args, &entry.returns, &manifest.determinism)?;
235                let qname = QName::new(plugin_id.as_str(), entry.name.clone());
236                let adapter =
237                    RhaiAggregateFn::new(Arc::clone(&runtime), entry.name.clone(), sig.clone());
238                registrar
239                    .aggregate_fn(qname.clone(), sig, Arc::new(adapter))
240                    .map_err(plugin_to_rhai_err)?;
241                aggregates_registered.push(qname.to_string());
242            }
243        }
244
245        let mut procedures_registered = Vec::with_capacity(manifest.procedures.len());
246        if effective.contains(&Capability::Procedure) {
247            for entry in &manifest.procedures {
248                let sig = build_procedure_signature(entry)?;
249                let qname = QName::new(plugin_id.as_str(), entry.name.clone());
250                let adapter =
251                    RhaiProcedure::new(Arc::clone(&runtime), entry.name.clone(), sig.clone());
252                registrar
253                    .procedure(qname.clone(), sig, Arc::new(adapter))
254                    .map_err(plugin_to_rhai_err)?;
255                procedures_registered.push(qname.to_string());
256            }
257        }
258
259        Ok(LoadOutcome {
260            plugin_id,
261            version: manifest.version,
262            effective_capabilities: effective,
263            denied_capabilities: denied,
264            scalars_registered,
265            aggregates_registered,
266            procedures_registered,
267            runtime,
268        })
269    }
270}
271
272fn build_procedure_signature(entry: &ProcedureEntry) -> Result<ProcedureSignature, RhaiError> {
273    let args: Vec<NamedArgType> = entry
274        .args
275        .iter()
276        .enumerate()
277        .map(|(i, t)| {
278            let ty = type_name_to_argtype(t)?;
279            Ok(NamedArgType {
280                name: format!("arg{i}").into(),
281                ty,
282                default: None,
283                doc: String::new(),
284            })
285        })
286        .collect::<Result<_, RhaiError>>()?;
287
288    let yields: Vec<Field> = entry
289        .yields
290        .iter()
291        .enumerate()
292        .map(|(i, t)| {
293            let dt = type_name_to_datatype(t)?;
294            Ok(Field::new(format!("col{i}"), dt, true))
295        })
296        .collect::<Result<_, RhaiError>>()?;
297
298    let mode = match entry.mode.trim().to_ascii_lowercase().as_str() {
299        "write" => ProcedureMode::Write,
300        "schema" => ProcedureMode::Schema,
301        "dbms" => ProcedureMode::Dbms,
302        _ => ProcedureMode::Read,
303    };
304    let side_effects = match mode {
305        ProcedureMode::Read => SideEffects::ReadOnly,
306        _ => SideEffects::Writes,
307    };
308
309    Ok(ProcedureSignature {
310        args,
311        yields,
312        mode,
313        side_effects,
314        retry_contract: None,
315        batch_input: None,
316        docs: String::new(),
317    })
318}
319
320fn derive_declared_capabilities(m: &RhaiManifest) -> CapabilitySet {
321    let mut set = CapabilitySet::new();
322    if !m.scalar_fns.is_empty() {
323        set.insert(Capability::ScalarFn);
324    }
325    if !m.aggregate_fns.is_empty() {
326        set.insert(Capability::AggregateFn);
327    }
328    if !m.procedures.is_empty() {
329        set.insert(Capability::Procedure);
330    }
331    set
332}
333
334fn intersect_caps(
335    declared: &CapabilitySet,
336    granted: &CapabilitySet,
337) -> (CapabilitySet, Vec<Capability>) {
338    let effective = declared.intersect(granted);
339    let denied: Vec<Capability> = declared
340        .iter()
341        .filter(|c| !granted.contains(c))
342        .cloned()
343        .collect();
344    (effective, denied)
345}
346
347fn plugin_to_rhai_err(e: PluginError) -> RhaiError {
348    match e {
349        PluginError::DuplicateRegistration(q) => {
350            RhaiError::ManifestInvalid(format!("duplicate registration: {q}"))
351        }
352        PluginError::CapabilityRequired(c) => {
353            RhaiError::ManifestInvalid(format!("registrar caps missing: {c:?}"))
354        }
355        other => RhaiError::Internal(format!("registrar: {other}")),
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use uni_plugin::PluginRegistry;
363
364    fn loader_with_caps() -> (RhaiLoader, CapabilitySet) {
365        let loader = RhaiLoader::new();
366        let caps = CapabilitySet::from_iter_of([
367            Capability::ScalarFn,
368            Capability::AggregateFn,
369            Capability::Procedure,
370        ]);
371        (loader, caps)
372    }
373
374    #[test]
375    fn loads_minimal_scalar_plugin() {
376        let script = r#"
377            fn uni_manifest() {
378                #{
379                    id: "ai.test.scalar",
380                    version: "0.1.0",
381                    scalar_fns: [
382                        #{ name: "double", args: ["float"], returns: "float" },
383                    ],
384                }
385            }
386            fn double(x) { x * 2.0 }
387        "#;
388        let (loader, caps) = loader_with_caps();
389        let registry = PluginRegistry::new();
390        let mut r = PluginRegistrar::new(PluginId::new("rhai.loading"), &caps, &registry);
391        let outcome = loader.load(script, &mut r, &caps).expect("loads");
392        assert_eq!(outcome.plugin_id.as_str(), "ai.test.scalar");
393        assert_eq!(outcome.scalars_registered.len(), 1);
394        assert!(outcome.denied_capabilities.is_empty());
395        r.commit_to_registry().expect("commits");
396        // Registry now has the qname.
397        let q = QName::new("ai.test.scalar", "double");
398        assert!(registry.scalar_fn(&q).is_some());
399    }
400
401    #[test]
402    fn declared_but_not_granted_caps_show_as_denied() {
403        let script = r#"
404            fn uni_manifest() {
405                #{
406                    id: "ai.test.denied",
407                    version: "0.1.0",
408                    scalar_fns: [
409                        #{ name: "noop", args: [], returns: "int" },
410                    ],
411                    aggregate_fns: [
412                        #{ name: "agg", args: ["float"], returns: "float", state: "map" },
413                    ],
414                }
415            }
416            fn noop() { 0 }
417        "#;
418        let loader = RhaiLoader::new();
419        let caps = CapabilitySet::from_iter_of([Capability::ScalarFn]);
420        let registry = PluginRegistry::new();
421        let mut r = PluginRegistrar::new(PluginId::new("rhai.loading"), &caps, &registry);
422        let outcome = loader.load(script, &mut r, &caps).expect("loads");
423        assert!(
424            outcome
425                .denied_capabilities
426                .contains(&Capability::AggregateFn)
427        );
428        assert_eq!(outcome.scalars_registered.len(), 1);
429    }
430
431    #[test]
432    fn parse_failure_returns_parse_error() {
433        let script = r#"this is not valid rhai @@@"#;
434        let (loader, caps) = loader_with_caps();
435        let registry = PluginRegistry::new();
436        let mut r = PluginRegistrar::new(PluginId::new("rhai.loading"), &caps, &registry);
437        let err = loader.load(script, &mut r, &caps).unwrap_err();
438        assert!(matches!(err, RhaiError::ParseFailed(_)));
439    }
440}