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