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