plecto_host/host.rs
1//! The wasmtime host: [`Host`] wires together the engines, trust policy, KV backend + quota,
2//! telemetry sink, and epoch ticker, and loads filter components into a [`LoadedFilter`].
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use anyhow::Result;
8use wasmtime::Engine;
9use wasmtime::component::{Component, HasSelf, Linker};
10
11use crate::contract::{
12 ContractVersion, FilterPreV01, FilterPreV02, FilterPreV03, FilterV01, FilterV02, FilterV03,
13 detect_contract_version,
14};
15use crate::engine::{Allocation, EpochTicker, TRUSTED_POOL_MAX, build_engine};
16use crate::errors::{LoadError, sbom_binds_component};
17use crate::filter::LoadedFilter;
18use crate::observe;
19#[cfg(feature = "outbound-http")]
20use crate::outbound_http;
21#[cfg(feature = "outbound-tcp")]
22use crate::outbound_tcp;
23use crate::pool::{LoadedInner, TrustedPool};
24use crate::quota::KvQuota;
25use crate::runtime::{FilterPreBinding, FilterRuntime, WasmtimeRuntime};
26#[cfg(any(
27 feature = "outbound-http",
28 feature = "outbound-tcp",
29 feature = "fat-guest"
30))]
31use crate::state::add_cli_runtime;
32use crate::state::{HostState, KV_NS_DELIM};
33use crate::{Isolation, KvBackend, LoadOptions, MemoryBackend, NoopSink, SignedArtifact};
34use crate::{TelemetrySink, TrustPolicy};
35
36/// The wasmtime host: two engines (one per isolation mode) plus the shared state backend.
37/// One per process/worker.
38pub struct Host {
39 /// Pooling-allocator engine for trusted, reused-instance filters (init-once).
40 trusted_engine: Engine,
41 /// On-demand engine for untrusted, fresh-per-request filters (memory fresh by
42 /// construction). Allocation strategy is per-Engine and immutable, so the trust split
43 /// is two engines, not one (confirmed wasmtime 45 behaviour).
44 untrusted_engine: Engine,
45 kv: Arc<dyn KvBackend>,
46 /// Per-namespace host-state accounting + caps, shared into every loaded filter.
47 kv_quota: Arc<KvQuota>,
48 /// Public keys this host trusts to sign filters (ADR 000006). Verified at every `load`.
49 trust: TrustPolicy,
50 /// Where loaded filters emit their per-execution spans (ADR 000009). Default `NoopSink`
51 /// (observability off); cloned into each filter at `load`, so set it before loading.
52 sink: Arc<dyn TelemetrySink>,
53 /// Shared tokio runtime that drives outbound-using filters (ADR 000036 / 000060): their guest
54 /// calls block on real socket I/O, which the no-reactor pollster executor cannot service.
55 /// Cloned into each outbound filter at `load`; unused by (and invisible to) filters without an
56 /// outbound policy.
57 #[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
58 outbound_rt: Arc<tokio::runtime::Runtime>,
59 /// Drives epoch deadlines for both engines; stops on drop. Held only for its lifetime.
60 _epoch_ticker: EpochTicker,
61}
62
63impl Host {
64 /// A host backed by an in-memory store (the default; process-lifetime state). `trust` is
65 /// the set of keys allowed to sign loadable filters (ADR 000006) — pass `TrustPolicy::empty()`
66 /// only if you intend that nothing can load.
67 pub fn new(trust: TrustPolicy) -> Result<Self> {
68 Self::with_backend(trust, Arc::new(MemoryBackend::default()))
69 }
70
71 /// Read-only residency metrics for the trusted (pooling) engine (wasmtime 46
72 /// `PoolingAllocatorMetrics`). A cheap, cloneable handle for perf probes / observability —
73 /// not the hot path. `unused_memory_bytes_resident()` reports bytes kept resident for
74 /// unused-but-warm pool slots (`linear_memory_keep_resident`, left at its default 0 here, so
75 /// this is expected to read ~0); `memories()` / `component_instances()` report the live count.
76 /// `None` if the trusted engine is not pooling (it always is here, so this returns `Some`).
77 pub fn pooling_allocator_metrics(&self) -> Option<wasmtime::PoolingAllocatorMetrics> {
78 self.trusted_engine.pooling_allocator_metrics()
79 }
80
81 /// A host backed by a caller-supplied store (e.g. `RedbBackend` for durability).
82 pub fn with_backend(trust: TrustPolicy, kv: Arc<dyn KvBackend>) -> Result<Self> {
83 let trusted_engine = build_engine(Allocation::Pooling)?;
84 let untrusted_engine = build_engine(Allocation::OnDemand)?;
85 let _epoch_ticker =
86 EpochTicker::spawn(vec![trusted_engine.clone(), untrusted_engine.clone()]);
87 // A MULTI-thread runtime (not current-thread): the host's public API is sync and driven from
88 // arbitrary worker threads, so `block_on` must be safe to call concurrently from several
89 // threads — a current-thread runtime serializes/contends there. Two workers suffice (outbound
90 // is I/O-bound, not CPU-bound) and bound the extra thread count (security-auditor F-001).
91 #[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
92 let outbound_rt = Arc::new(
93 tokio::runtime::Builder::new_multi_thread()
94 .worker_threads(2)
95 .enable_all()
96 .build()?,
97 );
98 Ok(Self {
99 trusted_engine,
100 untrusted_engine,
101 kv,
102 kv_quota: Arc::new(KvQuota::new()),
103 trust,
104 sink: Arc::new(NoopSink),
105 #[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
106 outbound_rt,
107 _epoch_ticker,
108 })
109 }
110
111 /// Set the telemetry sink (ADR 000009 observability stage). Filters loaded **after** this
112 /// emit one span per `on_request` / `on_response` to `sink`; the default is `NoopSink`
113 /// (observability off, zero cost). Builder style: `Host::new(trust)?.with_telemetry_sink(sink)`.
114 /// The sink is cloned into each filter at `load`, so set it before loading.
115 pub fn with_telemetry_sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
116 self.sink = sink;
117 self
118 }
119
120 /// Add `sink` ALONGSIDE the current one (fan-out) instead of replacing it — the composition
121 /// hook for wiring the OTLP export buffer (ADR 000040) next to whatever sink the caller
122 /// already set. Same load-time rule as [`with_telemetry_sink`](Self::with_telemetry_sink):
123 /// compose before loading filters.
124 pub fn with_added_telemetry_sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
125 self.sink = Arc::new(observe::FanOutSink::new(vec![self.sink, sink]));
126 self
127 }
128
129 /// Load a filter component under the given isolation mode. Type-checks and resolves
130 /// imports up front (`InstancePre`). For `Trusted`, the single persistent instance is
131 /// created now and `init` runs once; for `Untrusted`, instantiation is deferred to
132 /// each request.
133 ///
134 /// `filter_id` is the host-assigned identity used to namespace this filter's keyspace
135 /// (ADR 000011). It must be non-empty and free of the namespace delimiter; the filter
136 /// never sees or controls it. **Uniqueness is the caller's responsibility**: `load`
137 /// rejects an empty or delimiter-bearing id but not a duplicate, so loading the same id
138 /// twice shares one keyspace. A manifest-driven registry will assign and dedup ids
139 /// (ADR 000007).
140 pub fn load(
141 &self,
142 filter_id: &str,
143 artifact: &SignedArtifact<'_>,
144 opts: LoadOptions,
145 ) -> Result<LoadedFilter> {
146 self.load_inner(filter_id, artifact, opts)
147 .map_err(anyhow::Error::from)
148 }
149
150 /// The typed-error inner of [`Host::load`] (bp-rust: library code uses `thiserror`, not ad hoc
151 /// `anyhow::ensure!`). `load` stays `anyhow::Result` at the public boundary — unchanged, so it
152 /// keeps matching `plecto-control::ControlError::Load`'s existing `anyhow::Error` passthrough —
153 /// but every rejection here is a concrete, `downcast_ref`-able [`LoadError`] variant.
154 fn load_inner(
155 &self,
156 filter_id: &str,
157 artifact: &SignedArtifact<'_>,
158 opts: LoadOptions,
159 ) -> std::result::Result<LoadedFilter, LoadError> {
160 if filter_id.is_empty() {
161 return Err(LoadError::EmptyFilterId);
162 }
163 if filter_id.contains(KV_NS_DELIM) {
164 return Err(LoadError::FilterIdContainsDelimiter);
165 }
166
167 // --- provenance gate (ADR 000006): verify BEFORE instantiate, fail-closed. A
168 // --- missing / untrusted / tampered signature or a missing SBOM means we never
169 // --- touch the component bytes with wasmtime. Order is cheap-checks first.
170 if artifact.sbom.is_empty() {
171 return Err(LoadError::MissingSbom);
172 }
173 if !self
174 .trust
175 .verifies(artifact.component_signature, artifact.component_bytes)
176 {
177 return Err(LoadError::UnverifiedComponentSignature);
178 }
179 if !self.trust.verifies(artifact.sbom_signature, artifact.sbom) {
180 return Err(LoadError::UnverifiedSbomSignature);
181 }
182 // The SBOM must attest THIS component (its subject digest == sha256(component)), so a
183 // validly-signed but unrelated SBOM cannot be paired with it (review f000003 #1).
184 sbom_binds_component(artifact.sbom, artifact.component_bytes)?;
185
186 let component_bytes = artifact.component_bytes;
187 let engine = match opts.isolation {
188 Isolation::Trusted => &self.trusted_engine,
189 Isolation::Untrusted => &self.untrusted_engine,
190 };
191 let component = Component::from_binary(engine, component_bytes)?;
192 let version = detect_contract_version(&component, engine)
193 .ok_or(LoadError::UnsupportedContractVersion)?;
194 match version {
195 ContractVersion::V01 => {
196 // Once per process, not per load (ADR 000071): the operator needs the nudge, not
197 // a log line per hot-reload of the same fleet of legacy filters.
198 static V01_WARNED: std::sync::atomic::AtomicBool =
199 std::sync::atomic::AtomicBool::new(false);
200 if !V01_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
201 tracing::warn!(
202 filter = %filter_id,
203 "plecto:filter@0.1.0 is deprecated; rebuild filters for 0.3.0 \
204 (byte-valued headers + response request-context/replace)"
205 );
206 }
207 }
208 ContractVersion::V02 => {
209 // Same once-per-process nudge for the 0.2 track (ADR 000073).
210 static V02_WARNED: std::sync::atomic::AtomicBool =
211 std::sync::atomic::AtomicBool::new(false);
212 if !V02_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
213 tracing::warn!(
214 filter = %filter_id,
215 "plecto:filter@0.2.0 is deprecated; rebuild filters for 0.3.0 \
216 (response request-context + replace decision)"
217 );
218 }
219 }
220 ContractVersion::V03 => {}
221 }
222 let mut linker = Linker::<HostState>::new(engine);
223 // deny-by-default: lend ONLY the plecto host-API (every basic capability in one call),
224 // at the interface version this component targets — 0.1 / 0.2 / 0.3 instance names are
225 // distinct semver tracks and never cross-resolve. No WASI is added — unless this filter
226 // has an outbound policy (below).
227 match version {
228 ContractVersion::V03 => {
229 FilterV03::add_to_linker::<_, HasSelf<HostState>>(
230 &mut linker,
231 |s: &mut HostState| s,
232 )?;
233 }
234 ContractVersion::V02 => {
235 FilterV02::add_to_linker::<_, HasSelf<HostState>>(
236 &mut linker,
237 |s: &mut HostState| s,
238 )?;
239 }
240 ContractVersion::V01 => {
241 FilterV01::add_to_linker::<_, HasSelf<HostState>>(
242 &mut linker,
243 |s: &mut HostState| s,
244 )?;
245 }
246 }
247
248 // Outbound capabilities (ADR 000036 HTTP / ADR 000060 TCP): only when the operator lent
249 // this filter an allowlist do we add the MINIMAL WASI base (io / clocks / random / stdio +
250 // the inert wasi:cli runtime slice — NO fs, NO cli args/env of substance) plus the
251 // capability's own interfaces, still deny-by-default (every call is gated by the
252 // SSRF-guarded hooks / the vetted lookup + connect check). A filter without a policy links
253 // no WASI at all, exactly as before.
254 #[cfg(feature = "outbound-http")]
255 let outbound = opts
256 .outbound_http
257 .clone()
258 .map(outbound_http::OutboundState::new);
259 #[cfg(feature = "outbound-tcp")]
260 let outbound_tcp = opts.outbound_tcp.clone().map(|policy| {
261 let resolver = {
262 #[cfg(feature = "test-support")]
263 match opts.outbound_tcp_static_resolver.clone() {
264 Some(map) => crate::resolver::Resolver::Static(map),
265 None => crate::resolver::Resolver::System,
266 }
267 #[cfg(not(feature = "test-support"))]
268 crate::resolver::Resolver::System
269 };
270 outbound_tcp::OutboundTcpState::new(policy, resolver)
271 });
272 #[cfg(any(
273 feature = "outbound-http",
274 feature = "outbound-tcp",
275 feature = "fat-guest"
276 ))]
277 {
278 let mut needs_wasi_base = false;
279 #[cfg(feature = "outbound-http")]
280 {
281 needs_wasi_base |= outbound.is_some();
282 }
283 #[cfg(feature = "outbound-tcp")]
284 {
285 needs_wasi_base |= outbound_tcp.is_some();
286 }
287 #[cfg(feature = "fat-guest")]
288 {
289 needs_wasi_base |= opts.wasi_minimal;
290 }
291 if needs_wasi_base {
292 wasmtime_wasi::p2::add_to_linker_proxy_interfaces_async(&mut linker)?;
293 // The std guest's runtime also imports the rest of wasi:cli (environment / exit /
294 // terminal-*), each inert under the empty `WasiCtx`. Still NO filesystem — the
295 // capability boundary that matters (mirrors the streaming path, audit F-002).
296 add_cli_runtime(&mut linker)?;
297 }
298 }
299 // Fat guest (ADR 000063): TinyGo's wasip2 runtime unconditionally imports
300 // `wasi:filesystem/types` + `wasi:filesystem/preopens` even for a program that never
301 // touches a file (confirmed against TinyGo 0.41.1) — link an EMPTY filesystem (no
302 // preopened directory, ever) so such a guest instantiates while filesystem access stays
303 // structurally unreachable.
304 #[cfg(feature = "fat-guest")]
305 if opts.wasi_minimal {
306 crate::state::add_inert_filesystem(&mut linker)?;
307 }
308 #[cfg(feature = "outbound-http")]
309 if outbound.is_some() {
310 wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
311 }
312 // Outbound TCP (ADR 000060): the `wasi:sockets` TCP-connect slice ONLY — network /
313 // instance-network / tcp-create-socket / tcp behind the Store's socket_addr_check, plus
314 // the host's OWN vetted ip-name-lookup (the upstream one has no hostname filter). The UDP
315 // interfaces are never linked: the capability's absence, not a runtime deny.
316 #[cfg(feature = "outbound-tcp")]
317 if outbound_tcp.is_some() {
318 use wasmtime_wasi::p2::bindings::sockets;
319 use wasmtime_wasi::sockets::{WasiSockets, WasiSocketsView};
320 let getter = <HostState as WasiSocketsView>::sockets;
321 let net_opts = sockets::network::LinkOptions::default();
322 sockets::network::add_to_linker::<HostState, WasiSockets>(
323 &mut linker,
324 &net_opts,
325 getter,
326 )?;
327 sockets::instance_network::add_to_linker::<HostState, WasiSockets>(
328 &mut linker,
329 getter,
330 )?;
331 sockets::tcp_create_socket::add_to_linker::<HostState, WasiSockets>(
332 &mut linker,
333 getter,
334 )?;
335 sockets::tcp::add_to_linker::<HostState, WasiSockets>(&mut linker, getter)?;
336 sockets::ip_name_lookup::add_to_linker::<HostState, outbound_tcp::PlectoTcpLookup>(
337 &mut linker,
338 HostState::tcp_lookup,
339 )?;
340 }
341
342 let pre = match version {
343 ContractVersion::V03 => {
344 FilterPreBinding::V03(FilterPreV03::new(linker.instantiate_pre(&component)?)?)
345 }
346 ContractVersion::V02 => {
347 FilterPreBinding::V02(FilterPreV02::new(linker.instantiate_pre(&component)?)?)
348 }
349 ContractVersion::V01 => {
350 FilterPreBinding::V01(FilterPreV01::new(linker.instantiate_pre(&component)?)?)
351 }
352 };
353
354 // Zero-copy body bypass (ADR 000038 / ADR 000005 mechanism 2): a filter that inspects or
355 // transforms the request body ALSO exports `on-request-body` (world `filter-body`). Detect
356 // that export ONCE here; its absence tells the fast path the body never enters guest memory,
357 // so it streams straight through instead of buffering (fail-closed: presence ⇒ buffer).
358 let body_export = component.get_export_index(None, "on-request-body");
359
360 let runtime = WasmtimeRuntime {
361 engine: engine.clone(),
362 kv: self.kv.clone(),
363 kv_prefix: format!("{filter_id}{KV_NS_DELIM}"),
364 pre,
365 body_export,
366 init_deadline_ms: opts.init_deadline_ms,
367 request_deadline_ms: opts.request_deadline_ms,
368 max_memory_bytes: opts.max_memory_bytes,
369 ratelimit_bucket: opts.ratelimit_bucket,
370 kv_quota: self.kv_quota.clone(),
371 config: Arc::new(opts.config.clone()),
372 #[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
373 rt: {
374 let mut needs_rt = false;
375 #[cfg(feature = "outbound-http")]
376 {
377 needs_rt |= outbound.is_some();
378 }
379 #[cfg(feature = "outbound-tcp")]
380 {
381 needs_rt |= outbound_tcp.is_some();
382 }
383 needs_rt.then(|| self.outbound_rt.clone())
384 },
385 #[cfg(feature = "outbound-http")]
386 outbound,
387 #[cfg(feature = "outbound-tcp")]
388 outbound_tcp,
389 #[cfg(feature = "fat-guest")]
390 wasi_minimal: opts.wasi_minimal,
391 };
392
393 let trusted = match opts.isolation {
394 Isolation::Untrusted => None,
395 Isolation::Trusted => {
396 let cap = opts.trusted_pool_size.clamp(1, TRUSTED_POOL_MAX);
397 // Eager-build ONE instance now so a broken `init` surfaces at load (not on the
398 // first request) and a single-threaded caller then reuses it (init-once holds).
399 // The rest of the pool fills lazily, only when concurrency demands it (ADR 000012).
400 let first = runtime
401 .instantiate_initialized()
402 .map_err(LoadError::Instantiate)?;
403 Some(TrustedPool::new(
404 cap,
405 Duration::from_millis(opts.checkout_timeout_ms),
406 opts.max_requests_per_instance,
407 first,
408 ))
409 }
410 };
411
412 let inner = LoadedInner::new(
413 runtime,
414 filter_id.to_string(),
415 self.sink.clone(),
416 opts.isolation,
417 );
418
419 Ok(LoadedFilter { inner, trusted })
420 }
421}