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, 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}