Skip to main content

uni_plugin_extism/
loader.rs

1//! `ExtismLoader` — top-level entry point for loading Extism plugins.
2//!
3//! Manifest parsing, capability filtering, and real `extism-sdk`
4//! instantiation (with cap-filtered host fns + resource limits) ship
5//! here, alongside the end-to-end [`ExtismLoader::load`] path: read the
6//! manifest export → re-instantiate with effective grants → read the
7//! register export → push adapters into the `PluginRegistrar`.
8
9// Rust guideline compliant
10
11use std::collections::BTreeMap;
12
13use serde::Deserialize;
14
15use crate::error::ExtismError;
16use crate::host_fns::HostFnRegistry;
17
18/// Host-imposed default wall-clock budget per call when the manifest does not
19/// declare `timeout_ms`. Mirrors `uni_plugin_wasm::loader::DEFAULT_TIMEOUT_MS`
20/// so the Extism and Component-Model loaders sandbox identically.
21const DEFAULT_TIMEOUT_MS: u64 = 30_000;
22
23/// Host-imposed default linear-memory cap (in 64 KiB pages, = 1 GiB) when the
24/// manifest does not declare `memory_max_pages`. Mirrors
25/// `uni_plugin_wasm::loader::DEFAULT_MEMORY_MAX_PAGES`.
26const DEFAULT_MEMORY_MAX_PAGES: u32 = 16_384;
27
28/// Plugin manifest in the Extism plugin's canonical JSON form.
29///
30/// Returned from the plugin's `manifest` export. Mirrors the shape of
31/// the §14 manifest, but on the Extism wire.
32#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ExtismPluginManifest {
35    /// Reverse-DNS plugin id.
36    pub id: String,
37    /// Semver string.
38    pub version: String,
39    /// Extism ABI range the plugin was built against.
40    #[serde(default, rename = "abi-extism")]
41    pub abi_extism: Option<String>,
42    /// Capabilities the plugin declares it needs — each a bare name
43    /// (`"network"`) or a structured object with attenuation patterns
44    /// (`{"kind":"network","allow":[...]}`); see [`uni_plugin::ManifestCapability`].
45    #[serde(default)]
46    pub capabilities: Vec<uni_plugin::ManifestCapability>,
47    /// Determinism class (`"pure"`, `"session-scoped"`, `"nondeterministic"`).
48    #[serde(default)]
49    pub determinism: Option<String>,
50    /// Free-form human description.
51    #[serde(default)]
52    pub description: Option<String>,
53
54    // Resource limits. All optional — if absent, the host's defaults
55    // apply. Plugin authors can request tighter limits than the host
56    // default; the host's grant model decides whether to honor a looser
57    // request (M6a leaves the negotiation to the caller of `build_plugin`).
58    /// Per-call wasmtime fuel limit. Per proposal §10 / §5.5.4.
59    #[serde(default)]
60    pub fuel_per_call: Option<u64>,
61    /// Maximum linear-memory pages (one page = 64 KiB).
62    #[serde(default)]
63    pub memory_max_pages: Option<u32>,
64    /// Wall-clock per-call timeout in milliseconds.
65    #[serde(default)]
66    pub timeout_ms: Option<u64>,
67}
68
69impl ExtismPluginManifest {
70    /// The declared capabilities as a rich [`uni_plugin::CapabilitySet`].
71    #[must_use]
72    pub fn declared_capability_set(&self) -> uni_plugin::CapabilitySet {
73        uni_plugin::CapabilitySet::from_manifest(self.capabilities.iter().cloned())
74    }
75}
76
77/// Result of [`ExtismLoader::prepare`] — everything the host needs to
78/// instantiate the plugin once the SDK integration is wired.
79#[derive(Debug, Clone)]
80pub struct PreparedExtismPlugin {
81    /// Parsed manifest.
82    pub manifest: ExtismPluginManifest,
83    /// Capabilities granted to the plugin (rich, with attenuation patterns):
84    /// intersection of declared (manifest) and granted (host).
85    pub effective: uni_plugin::CapabilitySet,
86    /// Host fns the plugin is allowed to import (post-capability filter).
87    pub allowed_host_fns: Vec<String>,
88    /// Capabilities the plugin requested but the host did not grant —
89    /// the loader uses these for diagnostics and decides whether to
90    /// reject the load or proceed with reduced functionality.
91    pub denied_capabilities: Vec<String>,
92}
93
94/// Top-level Extism plugin loader.
95///
96/// Construct one per uni-db instance; the loader owns the
97/// [`HostFnRegistry`] (capability metadata) and a parallel map of the
98/// runtime-callable [`extism::Function`]s keyed by host-fn name. The
99/// metadata map exists unconditionally so embedders without
100/// `extism-runtime` can still introspect the host-fn surface; the
101/// runtime functions only materialize when the SDK feature is on.
102#[derive(Default)]
103pub struct ExtismLoader {
104    host_fns: HostFnRegistry,
105    /// Concrete host-fn implementations. Inserts via
106    /// [`Self::register_host_function`] keep this in lock-step with the
107    /// [`HostFnSpec`] metadata; `build_plugin` filters this map by
108    /// the plugin's effective capability set before handing functions to
109    /// `extism::PluginBuilder`.
110    // `extism::Function` doesn't implement Debug, so we hand-roll Debug
111    // for the enclosing type below.
112    runtime_fns: BTreeMap<String, extism::Function>,
113    /// Optional KMS provider backing `uni_kms_*`. Absent → those fns error
114    /// loudly at call time ("no KMS provider configured").
115    kms: Option<std::sync::Arc<dyn uni_plugin::KmsProvider>>,
116    /// Optional secret store backing `uni_secret_acquire`.
117    secrets: Option<std::sync::Arc<uni_plugin::secrets::SecretStore>>,
118    /// Optional HTTP egress backing `uni_http_*`.
119    http: Option<std::sync::Arc<dyn uni_plugin::HttpEgress>>,
120    /// Optional GraphCompute session registry backing `uni_graph_call`.
121    graph: Option<uni_plugin_builtin::algorithms::graph_compute::SharedRegistry>,
122}
123
124impl std::fmt::Debug for ExtismLoader {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("ExtismLoader")
127            .field("host_fns", &self.host_fns)
128            .field("runtime_fn_count", &self.runtime_fns.len())
129            .finish()
130    }
131}
132
133impl ExtismLoader {
134    /// Construct a fresh loader with an empty host-fn registry.
135    #[must_use]
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Mutable access to the host-fn registry (metadata).
141    pub fn host_fns_mut(&mut self) -> &mut HostFnRegistry {
142        &mut self.host_fns
143    }
144
145    /// Shared access to the host-fn registry (metadata).
146    #[must_use]
147    pub fn host_fns(&self) -> &HostFnRegistry {
148        &self.host_fns
149    }
150
151    /// Register a host function with both its metadata and its concrete
152    /// `extism::Function` implementation.
153    ///
154    /// The function is invocable from any plugin whose effective
155    /// capability set contains `spec.required_capability` (or any plugin,
156    /// if `required_capability` is `None`). The capability filter runs at
157    /// [`Self::build_plugin`] time — plugins that don't pass the filter
158    /// never see this function in their import table.
159    pub fn register_host_function(
160        &mut self,
161        spec: crate::host_fns::HostFnSpec,
162        function: extism::Function,
163    ) {
164        let name = spec.name.clone();
165        self.host_fns.register(spec);
166        self.runtime_fns.insert(name, function);
167    }
168
169    /// Number of registered runtime functions. Diagnostic / test helper.
170    #[must_use]
171    pub fn runtime_fn_count(&self) -> usize {
172        self.runtime_fns.len()
173    }
174
175    /// Names of the host fns a plugin holding `caps` is allowed to import.
176    ///
177    /// A host fn is allowed when its `required_capability` *variant* is in
178    /// `caps`, or when it declares no required capability (always
179    /// available). Pattern attenuation (key-id / secret-id / URL globs) is
180    /// enforced later, in the host-fn body — this is the structural,
181    /// link-time half of capability enforcement.
182    ///
183    /// Used both for the per-load allow-list ([`Self::prepare_parsed`],
184    /// against the effective `declared ∩ granted` set) and for the pass-1
185    /// bootstrap ([`Self::load`], against the host's *offered* grants).
186    /// Both call sites must produce byte-identical sets for the same
187    /// capability input, so the filter lives here once.
188    fn allowed_host_fn_names(&self, caps: &uni_plugin::CapabilitySet) -> Vec<String> {
189        self.host_fns
190            .iter()
191            .filter(|spec| match &spec.required_capability {
192                None => true,
193                Some(req) => caps.contains_variant(req),
194            })
195            .map(|s| s.name.clone())
196            .collect()
197    }
198
199    /// Attach a KMS provider backing `uni_kms_*` (builder style).
200    ///
201    /// Pair with [`crate::host_svc::register_default_host_svc`] to register the
202    /// metadata specs; the concrete functions are built per load with the
203    /// effective grant set so call-time attenuation is enforced.
204    #[must_use]
205    pub fn with_kms(mut self, kms: std::sync::Arc<dyn uni_plugin::KmsProvider>) -> Self {
206        self.kms = Some(kms);
207        self
208    }
209
210    /// Attach a secret store backing `uni_secret_acquire` (builder style).
211    #[must_use]
212    pub fn with_secret_store(
213        mut self,
214        store: std::sync::Arc<uni_plugin::secrets::SecretStore>,
215    ) -> Self {
216        self.secrets = Some(store);
217        self
218    }
219
220    /// Attach an HTTP egress backing `uni_http_*` (builder style).
221    #[must_use]
222    pub fn with_http(mut self, http: std::sync::Arc<dyn uni_plugin::HttpEgress>) -> Self {
223        self.http = Some(http);
224        self
225    }
226
227    /// Attach a GraphCompute session registry backing `uni_graph_call`.
228    #[must_use]
229    pub fn with_graph(
230        mut self,
231        registry: uni_plugin_builtin::algorithms::graph_compute::SharedRegistry,
232    ) -> Self {
233        self.graph = Some(registry);
234        self
235    }
236
237    /// Shared access to the GraphCompute registry, if configured.
238    #[must_use]
239    pub fn graph_registry(
240        &self,
241    ) -> Option<&uni_plugin_builtin::algorithms::graph_compute::SharedRegistry> {
242        self.graph.as_ref()
243    }
244
245    /// The host-fn map for a single load: the static `runtime_fns` plus the
246    /// per-load capability-gated service functions (`uni_kms_*`,
247    /// `uni_secret_acquire`, `uni_http_*`).
248    ///
249    /// Each service function is built with `prepared.effective` and the loader's
250    /// service handles baked into its [`extism::UserData`], so it enforces *this*
251    /// load's attenuation patterns. Only the names this plugin is actually
252    /// allowed (`prepared.allowed_host_fns`) are materialized, so a plugin
253    /// without the matching capability variant never pays the build cost.
254    fn runtime_fns_for_load(
255        &self,
256        prepared: &PreparedExtismPlugin,
257    ) -> BTreeMap<String, extism::Function> {
258        let mut fns = self.runtime_fns.clone();
259        // Build the per-load context once; cloned (cheaply, Arc handles) into
260        // each materialized service function.
261        let ctx = crate::host_svc::HostSvcCtx {
262            effective: prepared.effective.clone(),
263            kms: self.kms.clone(),
264            secrets: self.secrets.clone(),
265            http: self.http.clone(),
266            graph: self.graph.clone(),
267        };
268        for name in &prepared.allowed_host_fns {
269            if fns.contains_key(name) {
270                continue;
271            }
272            if let Some(function) = crate::host_svc::build_service_fn(name, &ctx) {
273                fns.insert(name.clone(), function);
274            }
275        }
276        fns
277    }
278
279    /// Parse a manifest JSON blob (as the plugin's `manifest` export
280    /// returns) and filter the host-fn registry through the granted
281    /// capability set.
282    ///
283    /// This is the **deterministic, sandbox-free** portion of the M6a
284    /// loader path: it doesn't instantiate any wasm. The host can use
285    /// the returned [`PreparedExtismPlugin`] to decide whether to
286    /// proceed with full SDK instantiation, prompt the user for
287    /// additional capability grants, or reject the load outright.
288    ///
289    /// # Errors
290    ///
291    /// - [`ExtismError::ManifestInvalid`] if the JSON doesn't parse or
292    ///   doesn't match [`ExtismPluginManifest`].
293    pub fn prepare(
294        &self,
295        manifest_json: &[u8],
296        grants: &uni_plugin::CapabilitySet,
297    ) -> Result<PreparedExtismPlugin, ExtismError> {
298        let manifest = crate::exports::parse_manifest_json(manifest_json)?;
299        Ok(self.prepare_parsed(manifest, grants))
300    }
301
302    /// Intersect declared/granted capabilities for an already-parsed
303    /// manifest, skipping the JSON round-trip.
304    ///
305    /// [`Self::load`] reads the manifest export off a bootstrap plugin
306    /// (parsed `ExtismPluginManifest`), then needs the combined
307    /// cap-intersection and host-fn-allow-list result. The previous
308    /// implementation re-serialized the parsed struct to JSON and called
309    /// [`Self::prepare`] which deserialized it straight back — a
310    /// wasteful round-trip whose only purpose was reusing the
311    /// cap-intersection loop. This entry point preserves the loop and
312    /// skips the (de)serialization.
313    #[must_use]
314    pub fn prepare_parsed(
315        &self,
316        manifest: ExtismPluginManifest,
317        grants: &uni_plugin::CapabilitySet,
318    ) -> PreparedExtismPlugin {
319        // Effective = declared ∩ granted (retains per-variant attenuation).
320        let declared = manifest.declared_capability_set();
321        let effective = declared.intersect(grants);
322        let denied: Vec<String> = declared
323            .iter()
324            .filter(|c| !effective.contains_variant(c))
325            .map(|c| format!("{c:?}"))
326            .collect();
327
328        // Host-fn filter: only fns whose required_capability *variant* is in
329        // the effective set (or which have no required_capability — always
330        // available). Pattern attenuation is enforced in the host-fn body.
331        let allowed = self.allowed_host_fn_names(&effective);
332
333        PreparedExtismPlugin {
334            manifest,
335            effective,
336            allowed_host_fns: allowed,
337            denied_capabilities: denied,
338        }
339    }
340
341    /// Build an `extism::Plugin` from raw wasm bytes against a prepared
342    /// capability set.
343    ///
344    /// Capability-gated host functions are filtered through
345    /// `prepared.allowed_host_fns` — fns whose `required_capability` is
346    /// not in the plugin's effective set are *omitted from the plugin's
347    /// import table*. This is the Extism analogue of Component Model's
348    /// linker absence: the plugin literally cannot resolve an unauthorized
349    /// host fn at link time. Per proposal §5.6.2 this is the structural
350    /// half of capability enforcement; the call-time pattern attenuation in
351    /// each `host_svc` body (`kms_allows` / `secret_allows` /
352    /// `network_allows`) is the defense-in-depth half.
353    ///
354    /// Resource limits declared in the parsed manifest are applied to
355    /// the underlying wasmtime config: `memory_max_pages` (linear
356    /// memory cap), `timeout_ms` (per-call wall-clock), `fuel_per_call`
357    /// (instruction budget). If a field is `None`, the host's default
358    /// (no cap) applies.
359    ///
360    /// # Errors
361    ///
362    /// - [`ExtismError::Instantiate`] if the wasm bytes fail to compile,
363    ///   link, or instantiate (invalid wasm, missing required imports,
364    ///   wasmtime errors).
365    /// - [`ExtismError::Internal`] if a runtime function recorded in the
366    ///   registry's allow-list is somehow absent from `runtime_fns`
367    ///   (should be unreachable; indicates a registry-state bug).
368    pub fn build_plugin(
369        &self,
370        bytes: &[u8],
371        prepared: &PreparedExtismPlugin,
372    ) -> Result<extism::Plugin, ExtismError> {
373        build_plugin_from_parts(bytes, prepared, &self.runtime_fns_for_load(prepared))
374    }
375
376    /// End-to-end load: read manifest, intersect with host grants,
377    /// re-instantiate with effective caps, read register export, push
378    /// adapters into the supplied [`uni_plugin::PluginRegistrar`].
379    ///
380    /// The two-pass dance is the proposal's §5.6 contract: the host
381    /// cannot know what capabilities the plugin needs until it reads
382    /// the `manifest` export, and reading that export requires a built
383    /// plugin. The first pass uses an **empty grant set** — the
384    /// `manifest` export must be implementable without any
385    /// capability-gated host fn, which is trivially true (it just
386    /// returns JSON). The second pass rebuilds with the intersected
387    /// grants and the register export is read against that.
388    ///
389    /// The currently-supported registration kinds are
390    /// [`crate::exports::RegistrationEntry::Scalar`]; aggregate and
391    /// procedure adapters land in M6a.2. Entries of unsupported kinds
392    /// cause [`ExtismError::OutputDecode`] — better to fail loudly than
393    /// silently ignore part of a plugin's surface.
394    ///
395    /// # Errors
396    ///
397    /// - [`ExtismError::Instantiate`] for wasmtime / extism build
398    ///   failures.
399    /// - [`ExtismError::ManifestInvalid`] for malformed manifests or
400    ///   unsupported argument types.
401    /// - [`ExtismError::InvalidPlugin`] if required exports
402    ///   (`manifest`, `register`) are missing.
403    /// - [`ExtismError::OutputDecode`] for malformed register payloads
404    ///   or unsupported entry kinds.
405    /// - [`ExtismError::Internal`] for `PluginRegistrar` registration
406    ///   failures (capability / qname conflicts).
407    pub fn load(
408        &self,
409        bytes: &[u8],
410        host_grants: &uni_plugin::CapabilitySet,
411        registrar: &mut uni_plugin::PluginRegistrar<'_>,
412    ) -> Result<LoadOutcome, ExtismError> {
413        // Pass 1: read the manifest export. A wasm module resolves *all* of
414        // its imports at instantiate time, so a guest that imports a host fn
415        // (e.g. `uni_http_get`) cannot even be instantiated to read its
416        // manifest unless that import is present in the linker. We therefore
417        // materialize the service fns whose capability variant the host offers
418        // (by NAME, for the linker) — but with an EMPTY effective grant set, so
419        // if the guest's `manifest` export actually *calls* one of them (nothing
420        // enforces manifest-export purity), the call is denied at the fn body's
421        // allow-list check instead of running with the host's full grants before
422        // `declared ∩ grants` intersection. Otherwise a zero-declaring plugin
423        // could exfiltrate/sign/read secrets during bootstrap. The live
424        // execution pool below is rebuilt with the real attenuation. A guest
425        // importing a host fn the host did *not* offer still fails to instantiate
426        // here, which is the intended link-time gate.
427        let bootstrap_allowed = self.allowed_host_fn_names(host_grants);
428        let bootstrap_prepared = PreparedExtismPlugin {
429            manifest: ExtismPluginManifest {
430                id: String::new(),
431                version: String::new(),
432                abi_extism: None,
433                capabilities: Vec::new(),
434                determinism: None,
435                description: None,
436                fuel_per_call: None,
437                memory_max_pages: None,
438                timeout_ms: None,
439            },
440            // Empty, NOT host_grants: bootstrap host-fn bodies must grant nothing.
441            effective: uni_plugin::CapabilitySet::new(),
442            allowed_host_fns: bootstrap_allowed,
443            denied_capabilities: Vec::new(),
444        };
445        let mut bootstrap_plugin = self.build_plugin(bytes, &bootstrap_prepared)?;
446        let parsed_manifest = crate::exports::read_manifest_export(&mut bootstrap_plugin)?;
447        drop(bootstrap_plugin);
448
449        // Rewrite the registrar's plugin id to match the manifest. The
450        // caller supplies a placeholder id (e.g., `"extism.loading"`)
451        // because the canonical id is unknown until pass 1 reads the
452        // manifest export. Setting it here lets `validate_qname`
453        // accept entries in the plugin's declared namespace.
454        registrar.set_plugin_id(uni_plugin::PluginId::new(parsed_manifest.id.clone()));
455
456        // Pass 2: intersect declared/granted, re-build with full host
457        // fn set, read register export. The parsed manifest from pass 1
458        // is reused directly via `prepare_parsed`, avoiding a JSON
459        // re-serialize / re-parse round-trip.
460        let prepared = self.prepare_parsed(parsed_manifest, host_grants);
461
462        // Build the instance pool: factory closes over owned bytes,
463        // prepared (cap-filtered), and the per-load host-fn map (static
464        // `runtime_fns` plus the capability-gated `uni_kms_*` / `uni_secret_*`
465        // / `uni_http_*` service fns built with this load's effective grant
466        // set). Pre-warm count is from `PoolConfig::default` (proposal §5.3.1 —
467        // `min_warm = 1`); future commits surface this through the manifest.
468        let pool = build_pool(bytes, &prepared, &self.runtime_fns_for_load(&prepared))?;
469
470        // Lease one warm instance, read the register export once, and
471        // drop the lease. A previous two-pass shape re-read the same
472        // export from a fresh instance; both reads were pure JSON
473        // parses of the same wasm export, so the second pass added no
474        // signal.
475        let mut leased = crate::pool::PooledInstance::acquire(std::sync::Arc::clone(&pool))?;
476        let registration = crate::exports::read_register_export(leased.get_mut())?;
477        drop(leased);
478
479        let mut scalars_registered: Vec<String> = Vec::new();
480        let mut aggregates_registered: Vec<String> = Vec::new();
481        let mut procedures_registered: Vec<String> = Vec::new();
482
483        for entry in registration.entries {
484            match entry {
485                crate::exports::RegistrationEntry::Scalar { qname, signature } => {
486                    let parsed_qname = parse_entry_qname(&qname)?;
487                    let sig = crate::wire_translate::wire_fn_sig_to_internal(&signature)?;
488                    let adapter = std::sync::Arc::new(crate::adapter::ExtismScalarFn::new(
489                        std::sync::Arc::clone(&pool),
490                        parsed_qname.clone(),
491                        sig.clone(),
492                    ));
493                    registrar
494                        .scalar_fn(parsed_qname, sig, adapter)
495                        .map_err(|e| {
496                            ExtismError::Internal(format!("registrar.scalar_fn `{qname}`: {e}"))
497                        })?;
498                    scalars_registered.push(qname);
499                }
500                crate::exports::RegistrationEntry::Aggregate {
501                    qname,
502                    signature,
503                    state,
504                } => {
505                    let parsed_qname = parse_entry_qname(&qname)?;
506                    let sig = crate::wire_translate::wire_agg_sig_to_internal(&signature, &state)?;
507                    let adapter =
508                        std::sync::Arc::new(crate::adapter_aggregate::ExtismAggregateFn::new(
509                            std::sync::Arc::clone(&pool),
510                            parsed_qname.clone(),
511                            sig.clone(),
512                        ));
513                    registrar
514                        .aggregate_fn(parsed_qname, sig, adapter)
515                        .map_err(|e| {
516                            ExtismError::Internal(format!("registrar.aggregate_fn `{qname}`: {e}"))
517                        })?;
518                    aggregates_registered.push(qname);
519                }
520                crate::exports::RegistrationEntry::Procedure {
521                    qname,
522                    args,
523                    yields,
524                    mode,
525                } => {
526                    let parsed_qname = parse_entry_qname(&qname)?;
527                    let sig =
528                        crate::wire_translate::wire_proc_sig_to_internal(&args, &yields, &mode)?;
529                    let adapter =
530                        std::sync::Arc::new(crate::adapter_procedure::ExtismProcedure::new(
531                            std::sync::Arc::clone(&pool),
532                            parsed_qname.clone(),
533                            sig.clone(),
534                        ));
535                    registrar
536                        .procedure(parsed_qname, sig, adapter)
537                        .map_err(|e| {
538                            ExtismError::Internal(format!("registrar.procedure `{qname}`: {e}"))
539                        })?;
540                    procedures_registered.push(qname);
541                }
542                crate::exports::RegistrationEntry::Algorithm {
543                    qname,
544                    args: _,
545                    yields,
546                } => {
547                    let parsed_qname = parse_entry_qname(&qname)?;
548                    let registry = self.graph.clone().ok_or_else(|| {
549                        ExtismError::Internal(format!(
550                            "algorithm `{qname}` needs a GraphCompute registry \
551                             (call ExtismLoader::with_graph)"
552                        ))
553                    })?;
554                    let sig = build_algorithm_signature(&yields)?;
555                    let adapter =
556                        std::sync::Arc::new(crate::adapter_algorithm::ExtismAlgorithm::new(
557                            std::sync::Arc::clone(&pool),
558                            registry,
559                            parsed_qname.clone(),
560                            sig,
561                        ));
562                    registrar.algorithm(parsed_qname, adapter).map_err(|e| {
563                        ExtismError::Internal(format!("registrar.algorithm `{qname}`: {e}"))
564                    })?;
565                }
566            }
567        }
568
569        Ok(LoadOutcome {
570            plugin_id: prepared.manifest.id.clone(),
571            version: prepared.manifest.version.clone(),
572            effective_capabilities: prepared
573                .effective
574                .iter()
575                .map(|c| format!("{c:?}"))
576                .collect(),
577            denied_capabilities: prepared.denied_capabilities,
578            scalars_registered,
579            aggregates_registered,
580            procedures_registered,
581            pool,
582        })
583    }
584}
585
586/// Parse a registration entry's qname, mapping a parse failure to
587/// [`ExtismError::OutputDecode`].
588///
589/// Shared by the three `RegistrationEntry` arms in [`ExtismLoader::load`]
590/// so every entry kind reports an invalid qname identically.
591fn parse_entry_qname(qname: &str) -> Result<uni_plugin::QName, ExtismError> {
592    uni_plugin::QName::parse(qname)
593        .map_err(|e| ExtismError::OutputDecode(format!("invalid qname `{qname}`: {e}")))
594}
595
596/// Build an `AlgorithmSignature` from declared `"name:type"` yield strings.
597fn build_algorithm_signature(
598    yields: &[String],
599) -> Result<uni_plugin::traits::algorithm::AlgorithmSignature, ExtismError> {
600    use arrow_schema::{DataType, Field};
601    let output_fields: Vec<Field> = yields
602        .iter()
603        .enumerate()
604        .map(|(i, spec)| {
605            let (name, type_name) = match spec.split_once(':') {
606                Some((n, t)) => (n.trim().to_string(), t.trim()),
607                None => (format!("col{i}"), spec.as_str()),
608            };
609            let dt = match type_name.to_ascii_lowercase().as_str() {
610                "int" | "integer" | "i64" => DataType::Int64,
611                "float" | "double" | "f64" => DataType::Float64,
612                other => {
613                    return Err(ExtismError::OutputDecode(format!(
614                        "algorithm yield type `{other}` unsupported (int/float)"
615                    )));
616                }
617            };
618            Ok(Field::new(name, dt, false))
619        })
620        .collect::<Result<_, ExtismError>>()?;
621    Ok(uni_plugin::traits::algorithm::AlgorithmSignature {
622        output_fields,
623        docs: String::new(),
624        ..Default::default()
625    })
626}
627
628/// Build an `extism::Plugin` from owned-data inputs.
629///
630/// Module-private free function so the pool factory closure can call
631/// it without holding a reference to the loader. The closure captures
632/// `Arc`-owned bytes / prepared / runtime_fns and re-invokes this each
633/// time the pool needs to cold-construct a new instance.
634fn build_plugin_from_parts(
635    bytes: &[u8],
636    prepared: &PreparedExtismPlugin,
637    runtime_fns: &BTreeMap<String, extism::Function>,
638) -> Result<extism::Plugin, ExtismError> {
639    let manifest = build_extism_manifest(bytes, &prepared.manifest);
640    let mut builder = extism::PluginBuilder::new(manifest).with_wasi(true);
641    if let Some(fuel) = prepared.manifest.fuel_per_call {
642        builder = builder.with_fuel_limit(fuel);
643    }
644    let mut selected: Vec<extism::Function> = Vec::with_capacity(prepared.allowed_host_fns.len());
645    for fn_name in &prepared.allowed_host_fns {
646        let function = runtime_fns.get(fn_name).ok_or_else(|| {
647            ExtismError::Internal(format!(
648                "allowed host fn `{fn_name}` missing from runtime_fns; \
649                 registry-state bug — every spec.name should have a Function"
650            ))
651        })?;
652        selected.push(function.clone());
653    }
654    builder = builder.with_functions(selected);
655    builder
656        .build()
657        .map_err(|e| ExtismError::Instantiate(e.to_string()))
658}
659
660fn build_extism_manifest(bytes: &[u8], plugin_manifest: &ExtismPluginManifest) -> extism::Manifest {
661    // Apply the host memory cap and wall-clock timeout UNCONDITIONALLY: an
662    // undeclared limit resolves to the host default rather than "unbounded", so
663    // an untrusted manifest cannot opt out of its own sandbox (a manifest with
664    // all limits `None` previously ran with no memory cap and no timeout).
665    // Mirrors the Component-Model loader's `EffectiveLimits::resolve`. A plugin
666    // may still declare a *larger* value if it genuinely needs one. (review H15)
667    let pages = plugin_manifest
668        .memory_max_pages
669        .unwrap_or(DEFAULT_MEMORY_MAX_PAGES);
670    let ms = plugin_manifest.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
671    extism::Manifest::new([extism::Wasm::data(bytes.to_vec())])
672        .with_memory_max(pages)
673        .with_timeout(std::time::Duration::from_millis(ms))
674}
675
676fn build_pool(
677    bytes: &[u8],
678    prepared: &PreparedExtismPlugin,
679    runtime_fns: &BTreeMap<String, extism::Function>,
680) -> Result<std::sync::Arc<crate::pool::ExtismInstancePool<extism::Plugin>>, ExtismError> {
681    let bytes_owned: std::sync::Arc<Vec<u8>> = std::sync::Arc::new(bytes.to_vec());
682    let prepared_owned: std::sync::Arc<PreparedExtismPlugin> =
683        std::sync::Arc::new(prepared.clone());
684    let runtime_fns_owned: std::sync::Arc<BTreeMap<String, extism::Function>> =
685        std::sync::Arc::new(runtime_fns.clone());
686
687    let factory = {
688        let bytes = std::sync::Arc::clone(&bytes_owned);
689        let prepared = std::sync::Arc::clone(&prepared_owned);
690        let runtime_fns = std::sync::Arc::clone(&runtime_fns_owned);
691        move || build_plugin_from_parts(&bytes, &prepared, &runtime_fns)
692    };
693
694    let pool = crate::pool::ExtismInstancePool::new(crate::pool::PoolConfig::default(), factory)?;
695    Ok(std::sync::Arc::new(pool))
696}
697
698/// Outcome of a successful [`ExtismLoader::load`].
699///
700/// Carries the diagnostic state the caller (typically `Uni::load_wasm_extism`)
701/// needs to construct a `PluginHandle`, surface denied capabilities to the
702/// user, and keep the live plugin alive for the duration of the
703/// registration.
704pub struct LoadOutcome {
705    /// Reverse-DNS plugin id from the manifest.
706    pub plugin_id: String,
707    /// Plugin version from the manifest.
708    pub version: String,
709    /// Capabilities granted to the plugin (intersection of declared ∩ host).
710    pub effective_capabilities: Vec<String>,
711    /// Capabilities the plugin requested but the host did not grant.
712    pub denied_capabilities: Vec<String>,
713    /// Qnames registered as scalar fns.
714    pub scalars_registered: Vec<String>,
715    /// Qnames registered as aggregate fns.
716    pub aggregates_registered: Vec<String>,
717    /// Qnames registered as procedures.
718    pub procedures_registered: Vec<String>,
719    /// The instance pool, shared across every adapter bound to this
720    /// plugin. Adapters hold an `Arc` clone; the pool is kept alive as
721    /// long as any adapter remains in the registry.
722    pub pool: std::sync::Arc<crate::pool::ExtismInstancePool<extism::Plugin>>,
723}
724
725impl std::fmt::Debug for LoadOutcome {
726    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727        f.debug_struct("LoadOutcome")
728            .field("plugin_id", &self.plugin_id)
729            .field("version", &self.version)
730            .field("effective_capabilities", &self.effective_capabilities)
731            .field("denied_capabilities", &self.denied_capabilities)
732            .field("scalars_registered", &self.scalars_registered)
733            .field("aggregates_registered", &self.aggregates_registered)
734            .field("procedures_registered", &self.procedures_registered)
735            .finish_non_exhaustive()
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::host_fns::HostFnSpec;
743    use uni_plugin::{Capability, CapabilitySet};
744
745    fn manifest_json(caps: &[&str]) -> String {
746        let caps_json: Vec<String> = caps.iter().map(|c| format!("\"{c}\"")).collect();
747        format!(
748            r#"{{ "id": "ai.example.test", "version": "1.0.0", "capabilities": [{}] }}"#,
749            caps_json.join(", ")
750        )
751    }
752
753    #[test]
754    fn loader_constructs_with_empty_host_fns() {
755        let l = ExtismLoader::new();
756        assert!(l.host_fns().is_empty());
757    }
758
759    // M6a.1.5: load() is now real. Smoke-test against garbage bytes —
760    // pass-1 build_plugin fails with Instantiate. Full e2e against a
761    // real plugin lives in tests/instantiate_with_minimal_wasm.rs and
762    // (T#7) tests/example_extism_geo_e2e.rs.
763
764    fn fs_cap() -> Capability {
765        Capability::Filesystem {
766            read: vec![],
767            write: vec![],
768        }
769    }
770
771    #[test]
772    fn loader_accepts_host_fn_registrations() {
773        let mut l = ExtismLoader::new();
774        l.host_fns_mut().register(HostFnSpec {
775            name: "host_fs_read".to_owned(),
776            required_capability: Some(fs_cap()),
777            docs: "Read file.".to_owned(),
778        });
779        assert_eq!(l.host_fns().len(), 1);
780    }
781
782    #[test]
783    fn prepare_parses_minimal_manifest() {
784        let l = ExtismLoader::new();
785        let json = manifest_json(&[]);
786        let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
787        assert_eq!(prep.manifest.id, "ai.example.test");
788        assert_eq!(prep.manifest.version, "1.0.0");
789        assert!(prep.effective.is_empty());
790        assert!(prep.denied_capabilities.is_empty());
791        assert!(prep.allowed_host_fns.is_empty());
792    }
793
794    #[test]
795    fn prepare_intersects_declared_and_granted_capabilities() {
796        let l = ExtismLoader::new();
797        // Declared (kebab bare names → zero-attenuation variants).
798        let json = manifest_json(&["filesystem", "network", "kms"]);
799        let grants = CapabilitySet::from_iter_of([fs_cap(), Capability::Network { allow: vec![] }]);
800        let prep = l.prepare(json.as_bytes(), &grants).unwrap();
801        // Granted: Filesystem + Network. Denied: Kms.
802        assert_eq!(prep.effective.len(), 2);
803        assert!(prep.effective.contains_variant(&fs_cap()));
804        assert!(
805            prep.effective
806                .contains_variant(&Capability::Network { allow: vec![] })
807        );
808        assert!(
809            !prep
810                .effective
811                .contains_variant(&Capability::Kms { key_ids: vec![] })
812        );
813    }
814
815    #[test]
816    fn prepare_filters_host_fns_through_effective_capabilities() {
817        let mut l = ExtismLoader::new();
818        l.host_fns_mut().register(HostFnSpec {
819            name: "host_fs_read".to_owned(),
820            required_capability: Some(fs_cap()),
821            docs: "Read file.".to_owned(),
822        });
823        l.host_fns_mut().register(HostFnSpec {
824            name: "host_net_http_get".to_owned(),
825            required_capability: Some(Capability::Network { allow: vec![] }),
826            docs: "HTTP GET.".to_owned(),
827        });
828        l.host_fns_mut().register(HostFnSpec {
829            name: "host_log".to_owned(),
830            required_capability: None, // always-available
831            docs: "Log a message.".to_owned(),
832        });
833
834        // Plugin requests filesystem only; host grants filesystem only.
835        let json = manifest_json(&["filesystem"]);
836        let prep = l
837            .prepare(json.as_bytes(), &CapabilitySet::from_iter_of([fs_cap()]))
838            .unwrap();
839
840        // host_log is always-available; host_fs_read enabled by grant;
841        // host_net_http_get filtered out (Network not granted).
842        assert_eq!(prep.allowed_host_fns.len(), 2);
843        assert!(prep.allowed_host_fns.iter().any(|n| n == "host_log"));
844        assert!(prep.allowed_host_fns.iter().any(|n| n == "host_fs_read"));
845        assert!(
846            !prep
847                .allowed_host_fns
848                .iter()
849                .any(|n| n == "host_net_http_get")
850        );
851    }
852
853    #[test]
854    fn prepare_rejects_malformed_manifest() {
855        let l = ExtismLoader::new();
856        let err = l.prepare(b"not json", &CapabilitySet::new()).unwrap_err();
857        assert!(matches!(err, ExtismError::ManifestInvalid(_)));
858    }
859
860    #[test]
861    fn build_plugin_rejects_garbage_bytes_as_instantiate_error() {
862        // M6a.1.1: `build_plugin` is real now. With garbage bytes,
863        // wasmtime fails to compile/instantiate — surface as
864        // `ExtismError::Instantiate`.
865        let l = ExtismLoader::new();
866        let prep = l
867            .prepare(
868                b"{\"id\":\"a.b\",\"version\":\"0.0.0\"}",
869                &CapabilitySet::new(),
870            )
871            .unwrap();
872        let err = l.build_plugin(b"not real wasm", &prep).unwrap_err();
873        assert!(
874            matches!(err, ExtismError::Instantiate(_)),
875            "expected Instantiate(_), got: {err:?}"
876        );
877    }
878
879    /// H15: a manifest that declares NO resource limits must still be sandboxed
880    /// — the host memory cap and timeout are applied unconditionally so an
881    /// untrusted plugin cannot opt out of its own limits.
882    #[test]
883    fn undeclared_limits_get_host_defaults() {
884        let l = ExtismLoader::new();
885        let json = manifest_json(&[]);
886        let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
887        // The manifest itself declares nothing.
888        assert_eq!(prep.manifest.memory_max_pages, None);
889        assert_eq!(prep.manifest.timeout_ms, None);
890
891        let m = build_extism_manifest(b"\0asm", &prep.manifest);
892        assert_eq!(
893            m.memory.max_pages,
894            Some(DEFAULT_MEMORY_MAX_PAGES),
895            "undeclared memory cap must fall back to the host default"
896        );
897        assert_eq!(
898            m.timeout_ms,
899            Some(DEFAULT_TIMEOUT_MS),
900            "undeclared timeout must fall back to the host default"
901        );
902    }
903
904    /// A manifest may still request its own (e.g. larger) limits — those are
905    /// honored rather than overwritten by the default.
906    #[test]
907    fn declared_limits_are_honored() {
908        let l = ExtismLoader::new();
909        let json = r#"{ "id": "ai.example.test", "version": "1.0.0", "capabilities": [], "memory_max_pages": 4, "timeout_ms": 500 }"#;
910        let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
911        let m = build_extism_manifest(b"\0asm", &prep.manifest);
912        assert_eq!(m.memory.max_pages, Some(4));
913        assert_eq!(m.timeout_ms, Some(500));
914    }
915}