plecto_host/options.rs
1//! Load-time options: [`Isolation`] (the instance-lifecycle choice) and [`LoadOptions`] (the
2//! full knob set for `Host::load`), plus their defaults.
3
4#[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
5use std::time::Duration;
6
7use crate::Bucket;
8#[cfg(any(feature = "outbound-http", feature = "outbound-tcp"))]
9use crate::outbound;
10
11/// How a filter is instantiated and isolated (ADR 000004 / 000011). Not a "trust score":
12/// it selects the **instance lifecycle**, mirroring how Fastly/Spin model per-request vs
13/// reusable sandboxes. *Who* is trusted is decided elsewhere (OCI signing, ADR 000006);
14/// this only says which lifecycle a loaded filter gets.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Isolation {
17 /// Own filters: a pool of reusable instances, `init` once per instance, checked out per
18 /// request (ADR 000012). No per-request zeroization (same trust domain). Statelessness
19 /// (Fork 4) is therefore honored by *trust*, not *enforced*: a trusted filter that stashes
20 /// mutable state in its own linear memory silently carries it across requests on a reused
21 /// instance — and, with a pool, *which* instance a request lands on becomes observable
22 /// (§6.6 footgun). That is not a security boundary (same trust domain); periodic recycling
23 /// (`max_requests_per_instance`) bounds the accumulation, but only `Untrusted`'s
24 /// fresh-per-request memory enforces statelessness structurally (ADR 000011).
25 Trusted,
26 /// Third-party filters: fresh instance per request, memory fresh by construction.
27 Untrusted,
28}
29
30/// Generous default budget for the heavy once-per-instance `init` of a **trusted** filter
31/// (Tenet 4): regex compile, schema build, config parse. Trusted init runs once per instance
32/// and is then reused, so a large budget is paid once — separate from, and much larger than,
33/// the per-request budget so a legitimately heavy init is not mistaken for a runaway (ADR 000006).
34const DEFAULT_INIT_DEADLINE_MS: u64 = 5_000;
35/// Tight default `init` budget for an **untrusted** filter. Untrusted filters instantiate fresh
36/// and re-run `init` on EVERY request (the isolation trade, ADR 000011), on the worker thread, so
37/// init is on the hot path: the generous 5s trusted budget would let an adversarial untrusted
38/// `init` busy-loop and pin a core for ~5s per request (CWE-770). Bound it near the
39/// per-request budget; an operator may still raise it per filter via the manifest.
40const DEFAULT_UNTRUSTED_INIT_DEADLINE_MS: u64 = 250;
41/// Tight default budget for the hot per-request hooks. This is a *safety* bound that traps
42/// runaway filters (infinite loops), not a latency SLA; header-only filters finish in well
43/// under a millisecond.
44const DEFAULT_REQUEST_DEADLINE_MS: u64 = 100;
45
46/// Default per-instance linear-memory cap enforced via a `StoreLimits` (ADR 000006). Matches
47/// the pooling engine's per-slot reservation so trusted and untrusted agree.
48pub(crate) const DEFAULT_MAX_MEMORY_BYTES: u64 = 64 << 20;
49
50/// Bounded wait (ms) for a free trusted instance before a checkout fails closed (ADR 000012).
51/// wasmtime's pooling allocator has no internal queue and the official guidance is for the
52/// embedder to apply its own backpressure; this is that wait. Kept short — orders of magnitude
53/// below a connection pool's seconds-long default — because on a gateway hot path it is better
54/// to shed load (`Unavailable`) than to queue unboundedly. M2 ties this to the real SLO.
55const DEFAULT_CHECKOUT_TIMEOUT_MS: u64 = 250;
56/// Recycle (discard + rebuild) a trusted instance after it has served this many requests
57/// (ADR 000012 / §6.6). Generous so steady-state reuse dominates (init-once still effectively
58/// holds), while still bounding accidental linear-memory state accumulation over an instance's
59/// life. Following Fastly's reusable-sandbox `max-requests`.
60const DEFAULT_MAX_REQUESTS_PER_INSTANCE: u64 = 1 << 16;
61/// Default ceiling for the auto-sized trusted pool (`available_parallelism`, clamped here).
62/// Modest so a multi-filter manifest does not, by default, multiply out past the engine's
63/// global pooling budget before the manifest registry (ADR 000007) can apportion it.
64const TRUSTED_POOL_DEFAULT_CEIL: usize = 8;
65
66/// Auto-sized default trusted pool capacity: worker-scale (foundation plan §6.3), approximated
67/// by `available_parallelism` until the fast-path server brings real worker threads (M2). A
68/// single-threaded caller still only ever builds one instance (lazy fill), so this does not
69/// change the init-once behaviour observed serially.
70fn default_trusted_pool_size() -> usize {
71 std::thread::available_parallelism()
72 .map(|n| n.get())
73 .unwrap_or(1)
74 .clamp(1, TRUSTED_POOL_DEFAULT_CEIL)
75}
76
77// --- outbound HTTP (ADR 000036) clamps. Operator- and guest-supplied timings/sizes are bounded to
78// --- these host maxima so a filter cannot claim an unboundedly long or large outbound call. ---
79/// Default TCP connect timeout for an outbound call.
80#[cfg(feature = "outbound-http")]
81const DEFAULT_OUTBOUND_CONNECT_TIMEOUT_MS: u64 = 2_000;
82/// Host ceiling on the connect timeout.
83#[cfg(feature = "outbound-http")]
84const MAX_OUTBOUND_CONNECT_TIMEOUT_MS: u64 = 10_000;
85/// Default wall-clock ceiling for the whole outbound call (connect + request + response). This is
86/// the host-side I/O deadline epoch interruption cannot provide (ADR 000006 / 000036).
87#[cfg(feature = "outbound-http")]
88const DEFAULT_OUTBOUND_TOTAL_TIMEOUT_MS: u64 = 5_000;
89/// Host ceiling on the total outbound timeout.
90#[cfg(feature = "outbound-http")]
91const MAX_OUTBOUND_TOTAL_TIMEOUT_MS: u64 = 30_000;
92/// Default cap on the response body the host buffers back to the guest.
93#[cfg(feature = "outbound-http")]
94const DEFAULT_OUTBOUND_MAX_RESPONSE_BYTES: u64 = 64 * 1024;
95/// Host ceiling on the response-body cap.
96#[cfg(feature = "outbound-http")]
97const MAX_OUTBOUND_MAX_RESPONSE_BYTES: u64 = 1 << 20;
98/// Default cap on concurrent in-flight outbound calls per filter.
99#[cfg(feature = "outbound-http")]
100const DEFAULT_OUTBOUND_MAX_CONCURRENT: u32 = 8;
101/// Host ceiling on per-filter outbound concurrency.
102#[cfg(feature = "outbound-http")]
103const MAX_OUTBOUND_MAX_CONCURRENT: u32 = 64;
104
105// --- outbound TCP (ADR 000060) clamps. ---
106/// Default per-request budget of TCP connects. Small: the reference use (a Redis consult) needs
107/// one connection, kept across requests on a pooled instance; per-request fan-out is the thing
108/// being bounded.
109#[cfg(feature = "outbound-tcp")]
110const DEFAULT_OUTBOUND_TCP_MAX_CONNECTIONS: u32 = 4;
111/// Host ceiling on the per-request connect budget.
112#[cfg(feature = "outbound-tcp")]
113const MAX_OUTBOUND_TCP_MAX_CONNECTIONS: u32 = 64;
114/// Default wall-clock ceiling on each guest hook call of an outbound-TCP filter. With raw TCP the
115/// host cannot see request boundaries inside the stream, so the deadline bounds the whole call —
116/// the role `total_timeout` plays for outbound HTTP (connect hangs AND read hangs, ADR 000060).
117#[cfg(feature = "outbound-tcp")]
118const DEFAULT_OUTBOUND_TCP_IO_DEADLINE_MS: u64 = 5_000;
119/// Host ceiling on the outbound-TCP hook-call deadline.
120#[cfg(feature = "outbound-tcp")]
121const MAX_OUTBOUND_TCP_IO_DEADLINE_MS: u64 = 30_000;
122
123/// Options for `Host::load`. A struct (not a bare arg) because deny-by-default grows more
124/// load-time knobs onto it. Defaults to the safe side: `Untrusted` (fail-closed) with
125/// metering on (ADR 000006). A future declarative manifest (ADR 000007) injects these.
126///
127/// Not `Copy`: the outbound policy (ADR 000036) carries an allowlist `Vec`, so this moves/clones.
128#[derive(Debug, Clone)]
129pub struct LoadOptions {
130 pub isolation: Isolation,
131 /// Epoch deadline (ms) for the once-per-instance `init` export.
132 pub init_deadline_ms: u64,
133 /// Epoch deadline (ms) for each per-request hook (`on-request` / `on-response`).
134 pub request_deadline_ms: u64,
135 /// Per-instance linear-memory cap (bytes), enforced by a `StoreLimits`.
136 pub max_memory_bytes: u64,
137 /// Trusted pool: maximum concurrent reusable instances (lazily filled, ADR 000012).
138 /// Clamped to `[1, TRUSTED_POOL_MAX]` at load. Ignored for `Untrusted` (fresh-per-request).
139 pub trusted_pool_size: usize,
140 /// Trusted pool: bounded wait (ms) for a free instance under saturation before failing
141 /// closed (`RunError::Unavailable`). Ignored for `Untrusted`.
142 pub checkout_timeout_ms: u64,
143 /// Trusted pool: recycle an instance (discard + rebuild) after this many requests, bounding
144 /// linear-memory state accumulation (§6.6). Ignored for `Untrusted`.
145 pub max_requests_per_instance: u64,
146 /// This filter's host-side token-bucket spec for `host-ratelimit` (manifest
147 /// `[filter.ratelimit]`, ADR 000026). `None` = the filter has no limiter (its `try-acquire`
148 /// fails closed). Host-configured so an untrusted filter cannot override its own limit.
149 pub ratelimit_bucket: Option<Bucket>,
150 /// This filter's outbound HTTP policy (manifest `[filter.outbound_http]`, ADR 000036): the
151 /// deny-by-default allowlist + SSRF opt-in + resource bounds enforced at the `wasi:http`
152 /// send seam. `None` = the filter is lent no outbound HTTP capability (the default).
153 #[cfg(feature = "outbound-http")]
154 pub outbound_http: Option<outbound::OutboundPolicy>,
155 /// This filter's outbound TCP policy (manifest `[filter.outbound_tcp]`, ADR 000060): the
156 /// deny-by-default allowlist + SSRF opt-in + resource bounds enforced at the host's
157 /// ip-name-lookup and connect seams. `None` = the filter is lent no outbound TCP capability
158 /// (the default).
159 #[cfg(feature = "outbound-tcp")]
160 pub outbound_tcp: Option<outbound::OutboundTcpPolicy>,
161 /// Test-only DNS override for the outbound TCP capability: `Some(map)` replaces the system
162 /// resolver so the feature-gated E2E suite can point an allowlisted NAME at a controlled
163 /// address deterministically. NOT production provenance — gated behind `test-support`.
164 #[doc(hidden)]
165 #[cfg(all(feature = "outbound-tcp", feature = "test-support"))]
166 pub outbound_tcp_static_resolver:
167 Option<std::collections::HashMap<String, Vec<std::net::IpAddr>>>,
168 /// This filter's business config (manifest `[filter.config]`, ADR 000066): an arbitrary
169 /// string→string map read back via `host-config::get`. The host never interprets keys or
170 /// values. Empty (the default) when the manifest declares no `[filter.config]` section — every
171 /// `get` then reads `None`, same as an undeclared key in a non-empty map.
172 pub config: std::collections::BTreeMap<String, String>,
173 /// This filter's WASI grant (manifest `wasi = "minimal"`, ADR 000063): a minimal, FIXED
174 /// slice — `wasi:io` / `wasi:clocks` / `wasi:random` / `wasi:cli` (empty env/args, no
175 /// filesystem, no sockets) — for guests whose runtime assumes WASI is present (TinyGo/Go and
176 /// similar "fat" guests). `false` (the default) keeps the filter zero-WASI (Tier A, ADR
177 /// 000055): the default Linker lends it none of this. Unlike `outbound_http`/`outbound_tcp`
178 /// there is no allowlist to configure — the grant is fixed, so this is a bare toggle.
179 #[cfg(feature = "fat-guest")]
180 pub wasi_minimal: bool,
181}
182
183impl Default for LoadOptions {
184 fn default() -> Self {
185 Self {
186 isolation: Isolation::Untrusted,
187 // default is untrusted → init re-runs per request, so bound it tight.
188 init_deadline_ms: DEFAULT_UNTRUSTED_INIT_DEADLINE_MS,
189 request_deadline_ms: DEFAULT_REQUEST_DEADLINE_MS,
190 max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
191 trusted_pool_size: default_trusted_pool_size(),
192 checkout_timeout_ms: DEFAULT_CHECKOUT_TIMEOUT_MS,
193 max_requests_per_instance: DEFAULT_MAX_REQUESTS_PER_INSTANCE,
194 ratelimit_bucket: None,
195 #[cfg(feature = "outbound-http")]
196 outbound_http: None,
197 #[cfg(feature = "outbound-tcp")]
198 outbound_tcp: None,
199 #[cfg(all(feature = "outbound-tcp", feature = "test-support"))]
200 outbound_tcp_static_resolver: None,
201 config: std::collections::BTreeMap::new(),
202 #[cfg(feature = "fat-guest")]
203 wasi_minimal: false,
204 }
205 }
206}
207
208impl LoadOptions {
209 pub fn trusted() -> Self {
210 Self {
211 isolation: Isolation::Trusted,
212 // trusted init runs ONCE per instance and is reused → keep the generous budget.
213 init_deadline_ms: DEFAULT_INIT_DEADLINE_MS,
214 ..Self::default()
215 }
216 }
217 pub fn untrusted() -> Self {
218 Self::default()
219 }
220 /// Override the per-request hook deadline (ms).
221 pub fn with_request_deadline_ms(mut self, ms: u64) -> Self {
222 self.request_deadline_ms = ms;
223 self
224 }
225 /// Override the `init` deadline (ms).
226 pub fn with_init_deadline_ms(mut self, ms: u64) -> Self {
227 self.init_deadline_ms = ms;
228 self
229 }
230 /// Override the per-instance linear-memory cap (bytes).
231 pub fn with_max_memory_bytes(mut self, bytes: u64) -> Self {
232 self.max_memory_bytes = bytes;
233 self
234 }
235 /// Override the trusted pool capacity (max concurrent reusable instances).
236 pub fn with_trusted_pool_size(mut self, n: usize) -> Self {
237 self.trusted_pool_size = n;
238 self
239 }
240 /// Override the bounded checkout wait (ms) before a saturated trusted pool fails closed.
241 pub fn with_checkout_timeout_ms(mut self, ms: u64) -> Self {
242 self.checkout_timeout_ms = ms;
243 self
244 }
245 /// Override how many requests a trusted instance serves before it is recycled.
246 pub fn with_max_requests_per_instance(mut self, n: u64) -> Self {
247 self.max_requests_per_instance = n;
248 self
249 }
250 /// Configure this filter's host-side `host-ratelimit` token bucket (ADR 000026). Without it,
251 /// the filter's `try-acquire` fails closed. The filter cannot supply or override these.
252 pub fn with_ratelimit_bucket(
253 mut self,
254 capacity: u64,
255 refill_tokens: u64,
256 refill_interval_ms: u64,
257 ) -> Self {
258 self.ratelimit_bucket = Some(Bucket {
259 capacity,
260 refill_tokens,
261 refill_interval_ms,
262 });
263 self
264 }
265
266 /// Lend this filter's manifest-declared business config (`[filter.config]`, ADR 000066) —
267 /// a read-only string map the filter reads back via `host-config::get`.
268 pub fn with_config(mut self, config: std::collections::BTreeMap<String, String>) -> Self {
269 self.config = config;
270 self
271 }
272
273 /// Lend this filter the outbound HTTP capability (ADR 000036) with an already-parsed
274 /// allowlist and private-range opt-in. Timings and sizes are clamped to host maxima here —
275 /// operator-supplied values cannot exceed the host ceiling, and guest-supplied request
276 /// options are clamped again at the send seam. The filter cannot supply or widen any of this.
277 #[cfg(feature = "outbound-http")]
278 #[allow(clippy::too_many_arguments)]
279 pub fn with_outbound_http(
280 mut self,
281 allow: Vec<outbound::AllowEntry>,
282 allow_private: Vec<String>,
283 connect_timeout_ms: Option<u64>,
284 total_timeout_ms: Option<u64>,
285 max_response_bytes: Option<u64>,
286 max_concurrent: Option<u32>,
287 ) -> Self {
288 let clamp_ms = |v: Option<u64>, def: u64, max: u64| v.unwrap_or(def).clamp(1, max);
289 // Parse the operator's CIDR strings; a malformed one is dropped, leaving that range blocked
290 // (fail-closed). The manifest validates them up front, so this is belt-and-suspenders.
291 let allow_private = allow_private
292 .iter()
293 .filter_map(|c| c.parse::<ipnet::IpNet>().ok())
294 .collect();
295 self.outbound_http = Some(outbound::OutboundPolicy {
296 allow,
297 allow_private,
298 connect_timeout: Duration::from_millis(clamp_ms(
299 connect_timeout_ms,
300 DEFAULT_OUTBOUND_CONNECT_TIMEOUT_MS,
301 MAX_OUTBOUND_CONNECT_TIMEOUT_MS,
302 )),
303 total_timeout: Duration::from_millis(clamp_ms(
304 total_timeout_ms,
305 DEFAULT_OUTBOUND_TOTAL_TIMEOUT_MS,
306 MAX_OUTBOUND_TOTAL_TIMEOUT_MS,
307 )),
308 max_response_bytes: max_response_bytes
309 .unwrap_or(DEFAULT_OUTBOUND_MAX_RESPONSE_BYTES)
310 .clamp(1, MAX_OUTBOUND_MAX_RESPONSE_BYTES),
311 max_concurrent: max_concurrent
312 .unwrap_or(DEFAULT_OUTBOUND_MAX_CONCURRENT)
313 .clamp(1, MAX_OUTBOUND_MAX_CONCURRENT),
314 });
315 self
316 }
317
318 /// Lend this filter the outbound TCP capability (ADR 000060) with an already-parsed allowlist
319 /// and private-range opt-in. The budget and deadline are clamped to host maxima here — the
320 /// filter cannot supply or widen any of this (same rule as `with_outbound_http`).
321 #[cfg(feature = "outbound-tcp")]
322 pub fn with_outbound_tcp(
323 mut self,
324 allow: Vec<outbound::TcpAllowEntry>,
325 allow_private: Vec<String>,
326 max_connections: Option<u32>,
327 io_deadline_ms: Option<u64>,
328 ) -> Self {
329 // Parse the operator's CIDR strings; a malformed one is dropped, leaving that range blocked
330 // (fail-closed). The manifest validates them up front, so this is belt-and-suspenders.
331 let allow_private = allow_private
332 .iter()
333 .filter_map(|c| c.parse::<ipnet::IpNet>().ok())
334 .collect();
335 self.outbound_tcp = Some(outbound::OutboundTcpPolicy {
336 allow,
337 allow_private,
338 max_connections: max_connections
339 .unwrap_or(DEFAULT_OUTBOUND_TCP_MAX_CONNECTIONS)
340 .clamp(1, MAX_OUTBOUND_TCP_MAX_CONNECTIONS),
341 io_deadline: Duration::from_millis(
342 io_deadline_ms
343 .unwrap_or(DEFAULT_OUTBOUND_TCP_IO_DEADLINE_MS)
344 .clamp(1, MAX_OUTBOUND_TCP_IO_DEADLINE_MS),
345 ),
346 });
347 self
348 }
349
350 /// Lend this filter the minimal WASI grant (ADR 000063): `wasi:io` / `wasi:clocks` /
351 /// `wasi:random` / `wasi:cli` (empty env/args, no filesystem, no sockets), plus its
352 /// stdout/stderr bridged into this filter's host-log. No allowlist to configure — the grant
353 /// is fixed, so this is a bare toggle (unlike `with_outbound_http`/`with_outbound_tcp`).
354 #[cfg(feature = "fat-guest")]
355 pub fn with_wasi_minimal(mut self) -> Self {
356 self.wasi_minimal = true;
357 self
358 }
359
360 /// Test-only: resolve outbound-TCP names from a static map instead of real DNS (see
361 /// `LoadOptions::outbound_tcp_static_resolver`).
362 #[doc(hidden)]
363 #[cfg(all(feature = "outbound-tcp", feature = "test-support"))]
364 pub fn with_outbound_tcp_static_resolver(
365 mut self,
366 entries: Vec<(String, Vec<std::net::IpAddr>)>,
367 ) -> Self {
368 self.outbound_tcp_static_resolver = Some(entries.into_iter().collect());
369 self
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn untrusted_init_deadline_is_tight_trusted_is_generous() {
379 // untrusted filters re-run init per request, so their default init budget must be
380 // bounded near the per-request budget, while a trusted filter (init once) keeps the
381 // generous budget.
382 assert_eq!(
383 LoadOptions::untrusted().init_deadline_ms,
384 DEFAULT_UNTRUSTED_INIT_DEADLINE_MS
385 );
386 assert_eq!(
387 LoadOptions::trusted().init_deadline_ms,
388 DEFAULT_INIT_DEADLINE_MS
389 );
390 assert!(
391 LoadOptions::untrusted().init_deadline_ms < LoadOptions::trusted().init_deadline_ms
392 );
393 }
394
395 #[cfg(feature = "fat-guest")]
396 #[test]
397 fn wasi_minimal_is_off_by_default_and_toggled_explicitly() {
398 assert!(!LoadOptions::untrusted().wasi_minimal);
399 assert!(!LoadOptions::trusted().wasi_minimal);
400 assert!(LoadOptions::untrusted().with_wasi_minimal().wasi_minimal);
401 }
402}