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}