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}