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