Skip to main content

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}