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;
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. The gate itself lives on
170 // --- `TrustPolicy::verify_artifact` so `plecto validate --resolve` runs the very
171 // --- same checks without an engine (field report §3.5).
172 self.trust.verify_artifact(artifact)?;
173
174 let component_bytes = artifact.component_bytes;
175 let engine = match opts.isolation {
176 Isolation::Trusted => &self.trusted_engine,
177 Isolation::Untrusted => &self.untrusted_engine,
178 };
179 let component = Component::from_binary(engine, component_bytes)?;
180 let version = detect_contract_version(&component, engine)
181 .ok_or(LoadError::UnsupportedContractVersion)?;
182 match version {
183 ContractVersion::V01 => {
184 // Once per process, not per load (ADR 000071): the operator needs the nudge, not
185 // a log line per hot-reload of the same fleet of legacy filters.
186 static V01_WARNED: std::sync::atomic::AtomicBool =
187 std::sync::atomic::AtomicBool::new(false);
188 if !V01_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
189 tracing::warn!(
190 filter = %filter_id,
191 "plecto:filter@0.1.0 is deprecated; rebuild filters for 0.3.0 \
192 (byte-valued headers + response request-context/replace)"
193 );
194 }
195 }
196 ContractVersion::V02 => {
197 // Same once-per-process nudge for the 0.2 track (ADR 000073).
198 static V02_WARNED: std::sync::atomic::AtomicBool =
199 std::sync::atomic::AtomicBool::new(false);
200 if !V02_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
201 tracing::warn!(
202 filter = %filter_id,
203 "plecto:filter@0.2.0 is deprecated; rebuild filters for 0.3.0 \
204 (response request-context + replace decision)"
205 );
206 }
207 }
208 ContractVersion::V03 => {}
209 }
210 let mut linker = Linker::<HostState>::new(engine);
211 // deny-by-default: lend ONLY the plecto host-API (every basic capability in one call),
212 // at the interface version this component targets — 0.1 / 0.2 / 0.3 instance names are
213 // distinct semver tracks and never cross-resolve. No WASI is added — unless this filter
214 // has an outbound policy (below).
215 match version {
216 ContractVersion::V03 => {
217 FilterV03::add_to_linker::<_, HasSelf<HostState>>(
218 &mut linker,
219 |s: &mut HostState| s,
220 )?;
221 }
222 ContractVersion::V02 => {
223 FilterV02::add_to_linker::<_, HasSelf<HostState>>(
224 &mut linker,
225 |s: &mut HostState| s,
226 )?;
227 }
228 ContractVersion::V01 => {
229 FilterV01::add_to_linker::<_, HasSelf<HostState>>(
230 &mut linker,
231 |s: &mut HostState| s,
232 )?;
233 }
234 }
235
236 // Outbound capabilities (ADR 000036 HTTP / ADR 000060 TCP): only when the operator lent
237 // this filter an allowlist do we add the MINIMAL WASI base (io / clocks / random / stdio +
238 // the inert wasi:cli runtime slice — NO fs, NO cli args/env of substance) plus the
239 // capability's own interfaces, still deny-by-default (every call is gated by the
240 // SSRF-guarded hooks / the vetted lookup + connect check). A filter without a policy links
241 // no WASI at all, exactly as before.
242 #[cfg(feature = "outbound-http")]
243 let outbound = opts
244 .outbound_http
245 .clone()
246 .map(outbound_http::OutboundState::new);
247 #[cfg(feature = "outbound-tcp")]
248 let outbound_tcp = opts.outbound_tcp.clone().map(|policy| {
249 let resolver = {
250 #[cfg(feature = "test-support")]
251 match opts.outbound_tcp_static_resolver.clone() {
252 Some(map) => crate::resolver::Resolver::Static(map),
253 None => crate::resolver::Resolver::System,
254 }
255 #[cfg(not(feature = "test-support"))]
256 crate::resolver::Resolver::System
257 };
258 outbound_tcp::OutboundTcpState::new(policy, resolver)
259 });
260 #[cfg(any(
261 feature = "outbound-http",
262 feature = "outbound-tcp",
263 feature = "fat-guest"
264 ))]
265 {
266 let mut needs_wasi_base = false;
267 #[cfg(feature = "outbound-http")]
268 {
269 needs_wasi_base |= outbound.is_some();
270 }
271 #[cfg(feature = "outbound-tcp")]
272 {
273 needs_wasi_base |= outbound_tcp.is_some();
274 }
275 #[cfg(feature = "fat-guest")]
276 {
277 needs_wasi_base |= opts.wasi_minimal;
278 }
279 if needs_wasi_base {
280 wasmtime_wasi::p2::add_to_linker_proxy_interfaces_async(&mut linker)?;
281 // The std guest's runtime also imports the rest of wasi:cli (environment / exit /
282 // terminal-*), each inert under the empty `WasiCtx`. Still NO filesystem — the
283 // capability boundary that matters (mirrors the streaming path, audit F-002).
284 add_cli_runtime(&mut linker)?;
285 }
286 }
287 // Fat guest (ADR 000063): TinyGo's wasip2 runtime unconditionally imports
288 // `wasi:filesystem/types` + `wasi:filesystem/preopens` even for a program that never
289 // touches a file (confirmed against TinyGo 0.41.1) — link an EMPTY filesystem (no
290 // preopened directory, ever) so such a guest instantiates while filesystem access stays
291 // structurally unreachable.
292 #[cfg(feature = "fat-guest")]
293 if opts.wasi_minimal {
294 crate::state::add_inert_filesystem(&mut linker)?;
295 }
296 #[cfg(feature = "outbound-http")]
297 if outbound.is_some() {
298 wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
299 }
300 // Outbound TCP (ADR 000060): the `wasi:sockets` TCP-connect slice ONLY — network /
301 // instance-network / tcp-create-socket / tcp behind the Store's socket_addr_check, plus
302 // the host's OWN vetted ip-name-lookup (the upstream one has no hostname filter). The UDP
303 // interfaces are never linked: the capability's absence, not a runtime deny.
304 #[cfg(feature = "outbound-tcp")]
305 if outbound_tcp.is_some() {
306 use wasmtime_wasi::p2::bindings::sockets;
307 use wasmtime_wasi::sockets::{WasiSockets, WasiSocketsView};
308 let getter = <HostState as WasiSocketsView>::sockets;
309 let net_opts = sockets::network::LinkOptions::default();
310 sockets::network::add_to_linker::<HostState, WasiSockets>(
311 &mut linker,
312 &net_opts,
313 getter,
314 )?;
315 sockets::instance_network::add_to_linker::<HostState, WasiSockets>(
316 &mut linker,
317 getter,
318 )?;
319 sockets::tcp_create_socket::add_to_linker::<HostState, WasiSockets>(
320 &mut linker,
321 getter,
322 )?;
323 sockets::tcp::add_to_linker::<HostState, WasiSockets>(&mut linker, getter)?;
324 sockets::ip_name_lookup::add_to_linker::<HostState, outbound_tcp::PlectoTcpLookup>(
325 &mut linker,
326 HostState::tcp_lookup,
327 )?;
328 }
329
330 let pre = match version {
331 ContractVersion::V03 => {
332 FilterPreBinding::V03(FilterPreV03::new(linker.instantiate_pre(&component)?)?)
333 }
334 ContractVersion::V02 => {
335 FilterPreBinding::V02(FilterPreV02::new(linker.instantiate_pre(&component)?)?)
336 }
337 ContractVersion::V01 => {
338 FilterPreBinding::V01(FilterPreV01::new(linker.instantiate_pre(&component)?)?)
339 }
340 };
341
342 // Zero-copy body bypass (ADR 000038 / ADR 000005 mechanism 2): a filter that inspects or
343 // transforms the request body ALSO exports `on-request-body` (world `filter-body`). Detect
344 // that export ONCE here; its absence tells the fast path the body never enters guest memory,
345 // so it streams straight through instead of buffering (fail-closed: presence ⇒ buffer).
346 let body_export = component.get_export_index(None, "on-request-body");
347
348 let runtime = WasmtimeRuntime {
349 engine: engine.clone(),
350 kv: self.kv.clone(),
351 kv_prefix: format!("{filter_id}{KV_NS_DELIM}"),
352 pre,
353 body_export,
354 init_deadline_ms: opts.init_deadline_ms,
355 request_deadline_ms: opts.request_deadline_ms,
356 max_memory_bytes: opts.max_memory_bytes,
357 ratelimit_bucket: opts.ratelimit_bucket,
358 kv_quota: self.kv_quota.clone(),
359 config: Arc::new(opts.config.clone()),
360 #[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
361 rt: {
362 let mut needs_rt = false;
363 #[cfg(feature = "outbound-http")]
364 {
365 needs_rt |= outbound.is_some();
366 }
367 #[cfg(feature = "outbound-tcp")]
368 {
369 needs_rt |= outbound_tcp.is_some();
370 }
371 needs_rt.then(|| self.outbound_rt.clone())
372 },
373 #[cfg(feature = "outbound-http")]
374 outbound,
375 #[cfg(feature = "outbound-tcp")]
376 outbound_tcp,
377 #[cfg(feature = "fat-guest")]
378 wasi_minimal: opts.wasi_minimal,
379 };
380
381 let trusted = match opts.isolation {
382 Isolation::Untrusted => None,
383 Isolation::Trusted => {
384 let cap = opts.trusted_pool_size.clamp(1, TRUSTED_POOL_MAX);
385 // Eager-build ONE instance now so a broken `init` surfaces at load (not on the
386 // first request) and a single-threaded caller then reuses it (init-once holds).
387 // The rest of the pool fills lazily, only when concurrency demands it (ADR 000012).
388 let first = runtime
389 .instantiate_initialized()
390 .map_err(LoadError::Instantiate)?;
391 Some(TrustedPool::new(
392 cap,
393 Duration::from_millis(opts.checkout_timeout_ms),
394 opts.max_requests_per_instance,
395 first,
396 ))
397 }
398 };
399
400 let inner = LoadedInner::new(
401 runtime,
402 filter_id.to_string(),
403 self.sink.clone(),
404 opts.isolation,
405 );
406
407 Ok(LoadedFilter { inner, trusted })
408 }
409}