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 = parse_entry_qname(&qname)?;
462 let sig = crate::wire_translate::wire_fn_sig_to_internal(&signature)?;
463 let adapter = std::sync::Arc::new(crate::adapter::ExtismScalarFn::new(
464 std::sync::Arc::clone(&pool),
465 parsed_qname.clone(),
466 sig.clone(),
467 ));
468 registrar
469 .scalar_fn(parsed_qname, sig, adapter)
470 .map_err(|e| {
471 ExtismError::Internal(format!("registrar.scalar_fn `{qname}`: {e}"))
472 })?;
473 scalars_registered.push(qname);
474 }
475 crate::exports::RegistrationEntry::Aggregate {
476 qname,
477 signature,
478 state,
479 } => {
480 let parsed_qname = parse_entry_qname(&qname)?;
481 let sig = crate::wire_translate::wire_agg_sig_to_internal(&signature, &state)?;
482 let adapter =
483 std::sync::Arc::new(crate::adapter_aggregate::ExtismAggregateFn::new(
484 std::sync::Arc::clone(&pool),
485 parsed_qname.clone(),
486 sig.clone(),
487 ));
488 registrar
489 .aggregate_fn(parsed_qname, sig, adapter)
490 .map_err(|e| {
491 ExtismError::Internal(format!("registrar.aggregate_fn `{qname}`: {e}"))
492 })?;
493 aggregates_registered.push(qname);
494 }
495 crate::exports::RegistrationEntry::Procedure {
496 qname,
497 args,
498 yields,
499 mode,
500 } => {
501 let parsed_qname = parse_entry_qname(&qname)?;
502 let sig =
503 crate::wire_translate::wire_proc_sig_to_internal(&args, &yields, &mode)?;
504 let adapter =
505 std::sync::Arc::new(crate::adapter_procedure::ExtismProcedure::new(
506 std::sync::Arc::clone(&pool),
507 parsed_qname.clone(),
508 sig.clone(),
509 ));
510 registrar
511 .procedure(parsed_qname, sig, adapter)
512 .map_err(|e| {
513 ExtismError::Internal(format!("registrar.procedure `{qname}`: {e}"))
514 })?;
515 procedures_registered.push(qname);
516 }
517 }
518 }
519
520 Ok(LoadOutcome {
521 plugin_id: prepared.manifest.id.clone(),
522 version: prepared.manifest.version.clone(),
523 effective_capabilities: prepared
524 .effective
525 .iter()
526 .map(|c| format!("{c:?}"))
527 .collect(),
528 denied_capabilities: prepared.denied_capabilities,
529 scalars_registered,
530 aggregates_registered,
531 procedures_registered,
532 pool,
533 })
534 }
535}
536
537/// Parse a registration entry's qname, mapping a parse failure to
538/// [`ExtismError::OutputDecode`].
539///
540/// Shared by the three `RegistrationEntry` arms in [`ExtismLoader::load`]
541/// so every entry kind reports an invalid qname identically.
542fn parse_entry_qname(qname: &str) -> Result<uni_plugin::QName, ExtismError> {
543 uni_plugin::QName::parse(qname)
544 .map_err(|e| ExtismError::OutputDecode(format!("invalid qname `{qname}`: {e}")))
545}
546
547/// Build an `extism::Plugin` from owned-data inputs.
548///
549/// Module-private free function so the pool factory closure can call
550/// it without holding a reference to the loader. The closure captures
551/// `Arc`-owned bytes / prepared / runtime_fns and re-invokes this each
552/// time the pool needs to cold-construct a new instance.
553fn build_plugin_from_parts(
554 bytes: &[u8],
555 prepared: &PreparedExtismPlugin,
556 runtime_fns: &BTreeMap<String, extism::Function>,
557) -> Result<extism::Plugin, ExtismError> {
558 let manifest = build_extism_manifest(bytes, &prepared.manifest);
559 let mut builder = extism::PluginBuilder::new(manifest).with_wasi(true);
560 if let Some(fuel) = prepared.manifest.fuel_per_call {
561 builder = builder.with_fuel_limit(fuel);
562 }
563 let mut selected: Vec<extism::Function> = Vec::with_capacity(prepared.allowed_host_fns.len());
564 for fn_name in &prepared.allowed_host_fns {
565 let function = runtime_fns.get(fn_name).ok_or_else(|| {
566 ExtismError::Internal(format!(
567 "allowed host fn `{fn_name}` missing from runtime_fns; \
568 registry-state bug — every spec.name should have a Function"
569 ))
570 })?;
571 selected.push(function.clone());
572 }
573 builder = builder.with_functions(selected);
574 builder
575 .build()
576 .map_err(|e| ExtismError::Instantiate(e.to_string()))
577}
578
579fn build_extism_manifest(bytes: &[u8], plugin_manifest: &ExtismPluginManifest) -> extism::Manifest {
580 // Apply the host memory cap and wall-clock timeout UNCONDITIONALLY: an
581 // undeclared limit resolves to the host default rather than "unbounded", so
582 // an untrusted manifest cannot opt out of its own sandbox (a manifest with
583 // all limits `None` previously ran with no memory cap and no timeout).
584 // Mirrors the Component-Model loader's `EffectiveLimits::resolve`. A plugin
585 // may still declare a *larger* value if it genuinely needs one. (review H15)
586 let pages = plugin_manifest
587 .memory_max_pages
588 .unwrap_or(DEFAULT_MEMORY_MAX_PAGES);
589 let ms = plugin_manifest.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
590 extism::Manifest::new([extism::Wasm::data(bytes.to_vec())])
591 .with_memory_max(pages)
592 .with_timeout(std::time::Duration::from_millis(ms))
593}
594
595fn build_pool(
596 bytes: &[u8],
597 prepared: &PreparedExtismPlugin,
598 runtime_fns: &BTreeMap<String, extism::Function>,
599) -> Result<std::sync::Arc<crate::pool::ExtismInstancePool<extism::Plugin>>, ExtismError> {
600 let bytes_owned: std::sync::Arc<Vec<u8>> = std::sync::Arc::new(bytes.to_vec());
601 let prepared_owned: std::sync::Arc<PreparedExtismPlugin> =
602 std::sync::Arc::new(prepared.clone());
603 let runtime_fns_owned: std::sync::Arc<BTreeMap<String, extism::Function>> =
604 std::sync::Arc::new(runtime_fns.clone());
605
606 let factory = {
607 let bytes = std::sync::Arc::clone(&bytes_owned);
608 let prepared = std::sync::Arc::clone(&prepared_owned);
609 let runtime_fns = std::sync::Arc::clone(&runtime_fns_owned);
610 move || build_plugin_from_parts(&bytes, &prepared, &runtime_fns)
611 };
612
613 let pool = crate::pool::ExtismInstancePool::new(crate::pool::PoolConfig::default(), factory)?;
614 Ok(std::sync::Arc::new(pool))
615}
616
617/// Outcome of a successful [`ExtismLoader::load`].
618///
619/// Carries the diagnostic state the caller (typically `Uni::load_wasm_extism`)
620/// needs to construct a `PluginHandle`, surface denied capabilities to the
621/// user, and keep the live plugin alive for the duration of the
622/// registration.
623pub struct LoadOutcome {
624 /// Reverse-DNS plugin id from the manifest.
625 pub plugin_id: String,
626 /// Plugin version from the manifest.
627 pub version: String,
628 /// Capabilities granted to the plugin (intersection of declared ∩ host).
629 pub effective_capabilities: Vec<String>,
630 /// Capabilities the plugin requested but the host did not grant.
631 pub denied_capabilities: Vec<String>,
632 /// Qnames registered as scalar fns.
633 pub scalars_registered: Vec<String>,
634 /// Qnames registered as aggregate fns.
635 pub aggregates_registered: Vec<String>,
636 /// Qnames registered as procedures.
637 pub procedures_registered: Vec<String>,
638 /// The instance pool, shared across every adapter bound to this
639 /// plugin. Adapters hold an `Arc` clone; the pool is kept alive as
640 /// long as any adapter remains in the registry.
641 pub pool: std::sync::Arc<crate::pool::ExtismInstancePool<extism::Plugin>>,
642}
643
644impl std::fmt::Debug for LoadOutcome {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 f.debug_struct("LoadOutcome")
647 .field("plugin_id", &self.plugin_id)
648 .field("version", &self.version)
649 .field("effective_capabilities", &self.effective_capabilities)
650 .field("denied_capabilities", &self.denied_capabilities)
651 .field("scalars_registered", &self.scalars_registered)
652 .field("aggregates_registered", &self.aggregates_registered)
653 .field("procedures_registered", &self.procedures_registered)
654 .finish_non_exhaustive()
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::host_fns::HostFnSpec;
662 use uni_plugin::{Capability, CapabilitySet};
663
664 fn manifest_json(caps: &[&str]) -> String {
665 let caps_json: Vec<String> = caps.iter().map(|c| format!("\"{c}\"")).collect();
666 format!(
667 r#"{{ "id": "ai.example.test", "version": "1.0.0", "capabilities": [{}] }}"#,
668 caps_json.join(", ")
669 )
670 }
671
672 #[test]
673 fn loader_constructs_with_empty_host_fns() {
674 let l = ExtismLoader::new();
675 assert!(l.host_fns().is_empty());
676 }
677
678 // M6a.1.5: load() is now real. Smoke-test against garbage bytes —
679 // pass-1 build_plugin fails with Instantiate. Full e2e against a
680 // real plugin lives in tests/instantiate_with_minimal_wasm.rs and
681 // (T#7) tests/example_extism_geo_e2e.rs.
682
683 fn fs_cap() -> Capability {
684 Capability::Filesystem {
685 read: vec![],
686 write: vec![],
687 }
688 }
689
690 #[test]
691 fn loader_accepts_host_fn_registrations() {
692 let mut l = ExtismLoader::new();
693 l.host_fns_mut().register(HostFnSpec {
694 name: "host_fs_read".to_owned(),
695 required_capability: Some(fs_cap()),
696 docs: "Read file.".to_owned(),
697 });
698 assert_eq!(l.host_fns().len(), 1);
699 }
700
701 #[test]
702 fn prepare_parses_minimal_manifest() {
703 let l = ExtismLoader::new();
704 let json = manifest_json(&[]);
705 let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
706 assert_eq!(prep.manifest.id, "ai.example.test");
707 assert_eq!(prep.manifest.version, "1.0.0");
708 assert!(prep.effective.is_empty());
709 assert!(prep.denied_capabilities.is_empty());
710 assert!(prep.allowed_host_fns.is_empty());
711 }
712
713 #[test]
714 fn prepare_intersects_declared_and_granted_capabilities() {
715 let l = ExtismLoader::new();
716 // Declared (kebab bare names → zero-attenuation variants).
717 let json = manifest_json(&["filesystem", "network", "kms"]);
718 let grants = CapabilitySet::from_iter_of([fs_cap(), Capability::Network { allow: vec![] }]);
719 let prep = l.prepare(json.as_bytes(), &grants).unwrap();
720 // Granted: Filesystem + Network. Denied: Kms.
721 assert_eq!(prep.effective.len(), 2);
722 assert!(prep.effective.contains_variant(&fs_cap()));
723 assert!(
724 prep.effective
725 .contains_variant(&Capability::Network { allow: vec![] })
726 );
727 assert!(
728 !prep
729 .effective
730 .contains_variant(&Capability::Kms { key_ids: vec![] })
731 );
732 }
733
734 #[test]
735 fn prepare_filters_host_fns_through_effective_capabilities() {
736 let mut l = ExtismLoader::new();
737 l.host_fns_mut().register(HostFnSpec {
738 name: "host_fs_read".to_owned(),
739 required_capability: Some(fs_cap()),
740 docs: "Read file.".to_owned(),
741 });
742 l.host_fns_mut().register(HostFnSpec {
743 name: "host_net_http_get".to_owned(),
744 required_capability: Some(Capability::Network { allow: vec![] }),
745 docs: "HTTP GET.".to_owned(),
746 });
747 l.host_fns_mut().register(HostFnSpec {
748 name: "host_log".to_owned(),
749 required_capability: None, // always-available
750 docs: "Log a message.".to_owned(),
751 });
752
753 // Plugin requests filesystem only; host grants filesystem only.
754 let json = manifest_json(&["filesystem"]);
755 let prep = l
756 .prepare(json.as_bytes(), &CapabilitySet::from_iter_of([fs_cap()]))
757 .unwrap();
758
759 // host_log is always-available; host_fs_read enabled by grant;
760 // host_net_http_get filtered out (Network not granted).
761 assert_eq!(prep.allowed_host_fns.len(), 2);
762 assert!(prep.allowed_host_fns.iter().any(|n| n == "host_log"));
763 assert!(prep.allowed_host_fns.iter().any(|n| n == "host_fs_read"));
764 assert!(
765 !prep
766 .allowed_host_fns
767 .iter()
768 .any(|n| n == "host_net_http_get")
769 );
770 }
771
772 #[test]
773 fn prepare_rejects_malformed_manifest() {
774 let l = ExtismLoader::new();
775 let err = l.prepare(b"not json", &CapabilitySet::new()).unwrap_err();
776 assert!(matches!(err, ExtismError::ManifestInvalid(_)));
777 }
778
779 #[test]
780 fn build_plugin_rejects_garbage_bytes_as_instantiate_error() {
781 // M6a.1.1: `build_plugin` is real now. With garbage bytes,
782 // wasmtime fails to compile/instantiate — surface as
783 // `ExtismError::Instantiate`.
784 let l = ExtismLoader::new();
785 let prep = l
786 .prepare(
787 b"{\"id\":\"a.b\",\"version\":\"0.0.0\"}",
788 &CapabilitySet::new(),
789 )
790 .unwrap();
791 let err = l.build_plugin(b"not real wasm", &prep).unwrap_err();
792 assert!(
793 matches!(err, ExtismError::Instantiate(_)),
794 "expected Instantiate(_), got: {err:?}"
795 );
796 }
797
798 /// H15: a manifest that declares NO resource limits must still be sandboxed
799 /// — the host memory cap and timeout are applied unconditionally so an
800 /// untrusted plugin cannot opt out of its own limits.
801 #[test]
802 fn undeclared_limits_get_host_defaults() {
803 let l = ExtismLoader::new();
804 let json = manifest_json(&[]);
805 let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
806 // The manifest itself declares nothing.
807 assert_eq!(prep.manifest.memory_max_pages, None);
808 assert_eq!(prep.manifest.timeout_ms, None);
809
810 let m = build_extism_manifest(b"\0asm", &prep.manifest);
811 assert_eq!(
812 m.memory.max_pages,
813 Some(DEFAULT_MEMORY_MAX_PAGES),
814 "undeclared memory cap must fall back to the host default"
815 );
816 assert_eq!(
817 m.timeout_ms,
818 Some(DEFAULT_TIMEOUT_MS),
819 "undeclared timeout must fall back to the host default"
820 );
821 }
822
823 /// A manifest may still request its own (e.g. larger) limits — those are
824 /// honored rather than overwritten by the default.
825 #[test]
826 fn declared_limits_are_honored() {
827 let l = ExtismLoader::new();
828 let json = r#"{ "id": "ai.example.test", "version": "1.0.0", "capabilities": [], "memory_max_pages": 4, "timeout_ms": 500 }"#;
829 let prep = l.prepare(json.as_bytes(), &CapabilitySet::new()).unwrap();
830 let m = build_extism_manifest(b"\0asm", &prep.manifest);
831 assert_eq!(m.memory.max_pages, Some(4));
832 assert_eq!(m.timeout_ms, Some(500));
833 }
834}