Skip to main content

plecto_control/manifest/
filter_entry.rs

1//! One filter to load (`[[filter]]`), pinned by OCI digest, plus its host-side rate-limit and
2//! outbound-HTTP sub-config (ADR 000006 / 000026 / 000036).
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8/// Host-side token-bucket spec for a filter's `host-ratelimit` (ADR 000026), set in the manifest
9/// as an inline table `ratelimit = { capacity = .., refill_tokens = .., refill_interval_ms = .. }`.
10/// The operator owns it, so an untrusted filter cannot supply or override its own limit (the WIT
11/// `try-acquire` carries only `key` + `cost`).
12#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
13#[serde(deny_unknown_fields)]
14pub struct RateLimitConfig {
15    /// Maximum tokens the bucket can hold.
16    pub capacity: u64,
17    /// Tokens added each refill interval.
18    pub refill_tokens: u64,
19    /// Refill interval in milliseconds (the host advances by whole intervals).
20    pub refill_interval_ms: u64,
21}
22
23/// A URL scheme an outbound allowlist entry may name (ADR 000036). Defaults to `https`.
24#[derive(
25    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
26)]
27#[serde(rename_all = "lowercase")]
28pub enum SchemeKind {
29    #[default]
30    Https,
31    Http,
32}
33
34impl SchemeKind {
35    #[cfg(feature = "outbound-http")]
36    pub(super) fn default_port(self) -> u16 {
37        match self {
38            SchemeKind::Https => 443,
39            SchemeKind::Http => 80,
40        }
41    }
42}
43
44/// One allowed outbound destination — an exact `(scheme, host, port)` triple (ADR 000036). No
45/// wildcards: the target endpoints (JWKS / introspection / ext_authz) are fixed and known.
46#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
47#[serde(deny_unknown_fields)]
48pub struct AllowDest {
49    /// Exact host — a DNS name (matched case-insensitively) or an IP literal.
50    pub host: String,
51    /// Destination port. Defaults to the scheme's default (443/80) when omitted.
52    pub port: Option<u16>,
53    /// URL scheme (default `https`).
54    #[serde(default)]
55    pub scheme: SchemeKind,
56}
57
58/// A filter's outbound HTTP policy (ADR 000036), declared as `[filter.outbound_http]`. The operator
59/// owns it — an untrusted filter cannot supply, widen, or override it (the
60/// `wasi:http/outgoing-handler` import carries only the request). Absent = the filter is lent no
61/// outbound HTTP capability.
62///
63/// `allow` is deny-by-default: only listed destinations are reachable. `allow_private` opts specific
64/// RFC1918 / ULA CIDRs past the SSRF guard's private-range block (for internal ext_authz); it never
65/// opens the always-blocked reserved floor (loopback / link-local / cloud-metadata). Timeouts and
66/// sizes are host-clamped.
67#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
68#[serde(deny_unknown_fields)]
69pub struct OutboundHttpConfig {
70    /// The exact destinations this filter may call. Must be non-empty when the section is present.
71    pub allow: Vec<AllowDest>,
72    /// Private/ULA CIDRs (e.g. `"10.1.0.0/16"`) this filter may reach despite the SSRF private-range
73    /// block. Empty (default) leaves all private space blocked.
74    #[serde(default)]
75    pub allow_private: Vec<String>,
76    /// TCP connect timeout (ms). Host default/ceiling apply when omitted/exceeded.
77    pub connect_timeout_ms: Option<u64>,
78    /// Whole-call wall-clock timeout (ms). Host default/ceiling apply when omitted/exceeded.
79    pub total_timeout_ms: Option<u64>,
80    /// Cap on the response body the host buffers back (bytes). Host default/ceiling apply.
81    pub max_response_bytes: Option<u64>,
82    /// Cap on concurrent in-flight outbound calls for this filter. Host default/ceiling apply.
83    pub max_concurrent: Option<u32>,
84}
85
86/// One allowed outbound TCP destination — an exact `(host, port)` pair (ADR 000060). Unlike HTTP's
87/// [`AllowDest`] there is no scheme and no default port: a raw TCP endpoint (Redis, memcached) has
88/// no scheme to derive one from, so the operator states the port explicitly.
89#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
90#[serde(deny_unknown_fields)]
91pub struct TcpAllowDest {
92    /// Exact host — a DNS name (matched case-insensitively) or an IP literal.
93    pub host: String,
94    /// Destination port (required — raw TCP has no scheme default).
95    pub port: u16,
96}
97
98/// A filter's outbound TCP policy (ADR 000060), declared as `[filter.outbound_tcp]`. Lends the
99/// `wasi:sockets` TCP-connect vocabulary behind the same three checks as outbound HTTP: the
100/// deny-by-default allowlist, the SSRF guard on host-resolved addresses (name-lookup vetting +
101/// IP pin), and host-clamped resource bounds. UDP and listen are never lent.
102#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct OutboundTcpConfig {
105    /// The exact destinations this filter may connect to. Must be non-empty when present.
106    pub allow: Vec<TcpAllowDest>,
107    /// Private/ULA CIDRs (e.g. `"10.1.0.0/16"`) this filter may reach despite the SSRF
108    /// private-range block. Empty (default) leaves all private space blocked.
109    #[serde(default)]
110    pub allow_private: Vec<String>,
111    /// Per-request budget of TCP connects (a connection held across requests costs only its
112    /// opening request). Host default/ceiling apply when omitted/exceeded.
113    pub max_connections: Option<u32>,
114    /// Wall-clock ceiling (ms) on each guest hook call for this filter — the host-side I/O
115    /// deadline epoch interruption cannot provide while the guest blocks in socket I/O
116    /// (connect *and* read hangs). Host default/ceiling apply when omitted/exceeded.
117    pub io_deadline_ms: Option<u64>,
118}
119
120/// One filter to load, pinned by OCI digest.
121#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
122#[serde(deny_unknown_fields)]
123pub struct FilterEntry {
124    /// Host-assigned identity; namespaces the filter's KV (ADR 000011) and names it in chains.
125    ///
126    /// KV continuity is **per-id and survives reload** (f000004 #4): reloading the same `id`
127    /// with a new `digest` keeps the same KV namespace, so the new version inherits the old
128    /// version's bytes (good for rate-limit counters — a reload doesn't reset them). If a new
129    /// version changes its state encoding, handle the migration or use a new `id` (= a fresh
130    /// namespace).
131    pub id: String,
132    /// Manifest-relative path to the local OCI image-layout for this filter.
133    pub source: String,
134    /// Pinned OCI image-manifest digest, `sha256:...` (reproducibility / supply chain).
135    pub digest: String,
136    #[serde(default)]
137    pub isolation: IsolationKind,
138    pub init_deadline_ms: Option<u64>,
139    pub request_deadline_ms: Option<u64>,
140    pub max_memory_bytes: Option<u64>,
141    /// Host-side token bucket for this filter's `host-ratelimit` (ADR 000026). Absent = the filter
142    /// has no limiter (its `try-acquire` fails closed). Operator-configured so an untrusted filter
143    /// cannot override its own limit.
144    pub ratelimit: Option<RateLimitConfig>,
145    /// This filter's outbound HTTP policy (ADR 000036), `[filter.outbound_http]`. Absent = no
146    /// outbound HTTP capability. Requires the `outbound-http` build; otherwise a present section is
147    /// rejected at validate (fail-closed).
148    #[serde(default)]
149    pub outbound_http: Option<OutboundHttpConfig>,
150    /// This filter's outbound TCP policy (ADR 000060), `[filter.outbound_tcp]`: the wasi:sockets
151    /// lend behind the same deny-by-default allowlist + SSRF floor shape as outbound HTTP. Absent =
152    /// no outbound TCP capability. Requires the `outbound-tcp` build; otherwise a present section
153    /// is rejected at validate (fail-closed).
154    #[serde(default)]
155    pub outbound_tcp: Option<OutboundTcpConfig>,
156    /// This filter's WASI grant (ADR 000063), `wasi = "none" | "minimal"`. Absent/`"none"` (the
157    /// default) keeps the filter zero-WASI (Tier A). Requires the `fat-guest` build; otherwise
158    /// `"minimal"` is rejected at validate (fail-closed).
159    #[serde(default)]
160    pub wasi: WasiKind,
161    /// This filter's business config (ADR 000066), `[filter.config]`: an arbitrary string→string
162    /// map the filter reads back via `host-config::get`. The host does not interpret keys or
163    /// values — only the filter does (e.g. Redis host/port, window seconds, `on_backend_error`).
164    /// Absent = an empty map (every `get` reads `None`). Unlike `ratelimit` / `outbound_*`, this is
165    /// a base capability with no feature gate.
166    #[serde(default)]
167    pub config: Option<BTreeMap<String, String>>,
168}
169
170/// Manifest spelling of the host's `Isolation`. Defaults to `untrusted` (fail-closed).
171#[derive(
172    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
173)]
174#[serde(rename_all = "lowercase")]
175pub enum IsolationKind {
176    #[default]
177    Untrusted,
178    Trusted,
179}
180
181/// A filter's WASI grant (ADR 000063). `minimal` lends the fixed, non-configurable slice —
182/// `wasi:io` / `wasi:clocks` / `wasi:random` / `wasi:cli` (no filesystem, no sockets), stdout/
183/// stderr bridged into this filter's `host-log` — for guests whose runtime assumes WASI is
184/// present (TinyGo/Go and similar "fat" guests). Unlike `outbound_http`/`outbound_tcp` there is
185/// no allowlist to declare: the grant is fixed, so this is a bare enum, not a sub-table. Requires
186/// the `fat-guest` build; otherwise `minimal` is rejected at validate (fail-closed).
187#[derive(
188    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
189)]
190#[serde(rename_all = "lowercase")]
191pub enum WasiKind {
192    #[default]
193    None,
194    Minimal,
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::manifest::Manifest;
201
202    #[test]
203    fn parses_filters_and_chain_with_defaults() {
204        let m = Manifest::from_toml(
205            r#"
206[[filter]]
207id = "auth"
208source = "artifacts/auth"
209digest = "sha256:abc"
210
211[[filter]]
212id = "rl"
213source = "artifacts/rl"
214digest = "sha256:def"
215isolation = "trusted"
216request_deadline_ms = 25
217
218[chain]
219filters = ["auth", "rl"]
220"#,
221        )
222        .unwrap();
223
224        assert_eq!(m.filters.len(), 2);
225        assert_eq!(m.filters[0].isolation, IsolationKind::Untrusted); // default
226        assert_eq!(m.filters[1].isolation, IsolationKind::Trusted);
227        assert_eq!(m.filters[1].request_deadline_ms, Some(25));
228        assert_eq!(m.chain.filters, vec!["auth".to_string(), "rl".to_string()]);
229    }
230
231    const OUTBOUND_TOML: &str = r#"
232[[filter]]
233id = "extauthz"
234source = "oci/extauthz"
235digest = "sha256:abc"
236
237[filter.outbound_http]
238allow = [
239  { host = "authz.example.com", port = 8443, scheme = "https" },
240  { host = "jwks.example.com" },
241]
242allow_private = ["10.1.0.0/16"]
243connect_timeout_ms = 1500
244"#;
245
246    #[test]
247    fn outbound_section_parses() {
248        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
249        let ob = m.filters[0]
250            .outbound_http
251            .as_ref()
252            .expect("outbound_http present");
253        assert_eq!(ob.allow.len(), 2);
254        assert_eq!(ob.allow[0].host, "authz.example.com");
255        assert_eq!(ob.allow[0].port, Some(8443));
256        assert_eq!(ob.allow[0].scheme, SchemeKind::Https);
257        assert_eq!(ob.allow[1].port, None); // defaulted at lowering time
258        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
259        assert_eq!(ob.connect_timeout_ms, Some(1500));
260    }
261
262    #[cfg(feature = "outbound-http")]
263    #[test]
264    fn outbound_validates_and_lowers_to_policy() {
265        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
266        let entry = &m.filters[0];
267        entry.validate().expect("valid outbound section");
268
269        let opts = entry.load_options();
270        let policy = opts
271            .outbound_http
272            .expect("outbound_http lowered into LoadOptions");
273        assert_eq!(policy.allow.len(), 2);
274        // exact allowlist matching + scheme default port
275        assert!(policy.allows(plecto_host::Scheme::Https, "authz.example.com", 8443));
276        assert!(policy.allows(plecto_host::Scheme::Https, "jwks.example.com", 443));
277        assert!(!policy.allows(plecto_host::Scheme::Http, "authz.example.com", 8443));
278        // allow_private opt-in reaches the SSRF classifier
279        assert_eq!(
280            policy.classify("10.1.2.3".parse().unwrap()),
281            plecto_host::AddrVerdict::Allowed
282        );
283        assert_eq!(
284            policy.classify("169.254.169.254".parse().unwrap()),
285            plecto_host::AddrVerdict::BlockedReserved
286        );
287        // small value passes through; the connect timeout was 1500ms
288        assert_eq!(
289            policy.connect_timeout,
290            std::time::Duration::from_millis(1500)
291        );
292    }
293
294    #[cfg(feature = "outbound-http")]
295    #[test]
296    fn outbound_clamps_oversized_values() {
297        let toml = r#"
298[[filter]]
299id = "x"
300source = "s"
301digest = "sha256:abc"
302[filter.outbound_http]
303allow = [{ host = "a.example.com" }]
304total_timeout_ms = 999999999
305max_concurrent = 100000
306"#;
307        let m = Manifest::from_toml(toml).unwrap();
308        let policy = m.filters[0].load_options().outbound_http.unwrap();
309        assert!(policy.total_timeout <= std::time::Duration::from_secs(30));
310        assert!(policy.max_concurrent <= 64);
311    }
312
313    #[cfg(feature = "outbound-http")]
314    #[test]
315    fn outbound_rejects_bad_config() {
316        let cases = [
317            // unparseable CIDR
318            (
319                "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
320                "bad CIDR",
321            ),
322            // zero timeout
323            (
324                "allow = [{ host = \"a\" }]\nconnect_timeout_ms = 0",
325                "zero connect timeout",
326            ),
327        ];
328        for (body, why) in cases {
329            let toml = format!(
330                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_http]\n{body}\n"
331            );
332            let m = Manifest::from_toml(&toml).unwrap();
333            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
334        }
335    }
336
337    /// Empty `allow` is deny-by-default (no reachable destination) but still links wasi:http —
338    /// required by wasip2 guests that import the interface even when unused (filter-jwt static path).
339    #[cfg(feature = "outbound-http")]
340    #[test]
341    fn outbound_allows_empty_allowlist_as_deny_all() {
342        let toml = r#"
343[[filter]]
344id = "x"
345source = "s"
346digest = "sha256:abc"
347[filter.outbound_http]
348allow = []
349"#;
350        let m = Manifest::from_toml(toml).unwrap();
351        m.filters[0]
352            .validate()
353            .expect("empty allow is deny-all, not invalid");
354        let opts = m.filters[0].load_options();
355        assert!(opts.outbound_http.is_some());
356        assert!(opts.outbound_http.unwrap().allow.is_empty());
357    }
358
359    const OUTBOUND_TCP_TOML: &str = r#"
360[[filter]]
361id = "ratelimit-redis"
362source = "oci/ratelimit-redis"
363digest = "sha256:abc"
364
365[filter.outbound_tcp]
366allow = [{ host = "redis.internal", port = 6379 }]
367allow_private = ["10.1.0.0/16"]
368max_connections = 2
369io_deadline_ms = 1500
370"#;
371
372    #[test]
373    fn outbound_tcp_section_parses() {
374        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
375        let ob = m.filters[0]
376            .outbound_tcp
377            .as_ref()
378            .expect("outbound_tcp present");
379        assert_eq!(ob.allow.len(), 1);
380        assert_eq!(ob.allow[0].host, "redis.internal");
381        assert_eq!(ob.allow[0].port, 6379);
382        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
383        assert_eq!(ob.max_connections, Some(2));
384        assert_eq!(ob.io_deadline_ms, Some(1500));
385    }
386
387    #[test]
388    fn outbound_tcp_port_is_required() {
389        // No scheme → no default port; an entry without one must fail to parse, not silently
390        // default.
391        let toml = r#"
392[[filter]]
393id = "x"
394source = "s"
395digest = "sha256:abc"
396[filter.outbound_tcp]
397allow = [{ host = "redis.internal" }]
398"#;
399        assert!(Manifest::from_toml(toml).is_err(), "port is mandatory");
400    }
401
402    #[cfg(feature = "outbound-tcp")]
403    #[test]
404    fn outbound_tcp_validates_and_lowers_to_policy() {
405        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
406        let entry = &m.filters[0];
407        entry.validate().expect("valid outbound_tcp section");
408
409        let opts = entry.load_options();
410        let policy = opts
411            .outbound_tcp
412            .expect("outbound_tcp lowered into LoadOptions");
413        assert_eq!(policy.allow.len(), 1);
414        assert!(policy.allows_name("REDIS.internal")); // DNS case-insensitive
415        assert!(!policy.allows_name("evil.internal"));
416        // allow_private opt-in reaches the shared SSRF classifier; the floor stays closed
417        assert_eq!(
418            policy.classify("10.1.2.3".parse().unwrap()),
419            plecto_host::AddrVerdict::Allowed
420        );
421        assert_eq!(
422            policy.classify("169.254.169.254".parse().unwrap()),
423            plecto_host::AddrVerdict::BlockedReserved
424        );
425        assert_eq!(policy.max_connections, 2);
426        assert_eq!(policy.io_deadline, std::time::Duration::from_millis(1500));
427    }
428
429    #[cfg(feature = "outbound-tcp")]
430    #[test]
431    fn outbound_tcp_clamps_oversized_values() {
432        let toml = r#"
433[[filter]]
434id = "x"
435source = "s"
436digest = "sha256:abc"
437[filter.outbound_tcp]
438allow = [{ host = "redis.internal", port = 6379 }]
439max_connections = 100000
440io_deadline_ms = 999999999
441"#;
442        let m = Manifest::from_toml(toml).unwrap();
443        let policy = m.filters[0].load_options().outbound_tcp.unwrap();
444        assert!(policy.max_connections <= 64);
445        assert!(policy.io_deadline <= std::time::Duration::from_secs(30));
446    }
447
448    #[cfg(feature = "outbound-tcp")]
449    #[test]
450    fn outbound_tcp_rejects_bad_config() {
451        let cases = [
452            ("allow = []", "empty allowlist"),
453            ("allow = [{ host = \"a\", port = 0 }]", "port 0"),
454            (
455                "allow = [{ host = \"a\", port = 6379 }]\nallow_private = [\"not-a-cidr\"]",
456                "bad CIDR",
457            ),
458            (
459                "allow = [{ host = \"a\", port = 6379 }]\nmax_connections = 0",
460                "zero connect budget",
461            ),
462            (
463                "allow = [{ host = \"a\", port = 6379 }]\nio_deadline_ms = 0",
464                "zero io deadline",
465            ),
466        ];
467        for (body, why) in cases {
468            let toml = format!(
469                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_tcp]\n{body}\n"
470            );
471            let m = Manifest::from_toml(&toml).unwrap();
472            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
473        }
474    }
475
476    #[cfg(not(feature = "outbound-tcp"))]
477    #[test]
478    fn outbound_tcp_rejected_without_feature() {
479        // A manifest that asks for outbound TCP must fail closed on a build that cannot provide it.
480        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
481        assert!(
482            m.filters[0].validate().is_err(),
483            "outbound_tcp requires the outbound-tcp build"
484        );
485    }
486
487    #[cfg(not(feature = "outbound-http"))]
488    #[test]
489    fn outbound_rejected_without_feature() {
490        // A manifest that asks for outbound must fail closed on a build that cannot provide it.
491        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
492        assert!(
493            m.filters[0].validate().is_err(),
494            "outbound_http requires the outbound-http build"
495        );
496    }
497
498    const WASI_MINIMAL_TOML: &str = r#"
499[[filter]]
500id = "hello-go"
501source = "oci/hello-go"
502digest = "sha256:abc"
503wasi = "minimal"
504"#;
505
506    #[test]
507    fn wasi_defaults_to_none() {
508        let m = Manifest::from_toml(
509            r#"
510[[filter]]
511id = "x"
512source = "s"
513digest = "sha256:abc"
514"#,
515        )
516        .unwrap();
517        assert_eq!(m.filters[0].wasi, WasiKind::None);
518    }
519
520    #[test]
521    fn wasi_minimal_parses() {
522        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
523        assert_eq!(m.filters[0].wasi, WasiKind::Minimal);
524    }
525
526    #[cfg(feature = "fat-guest")]
527    #[test]
528    fn wasi_minimal_validates_and_lowers_to_load_options() {
529        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
530        let entry = &m.filters[0];
531        entry.validate().expect("valid wasi = \"minimal\" section");
532        assert!(entry.load_options().wasi_minimal);
533    }
534
535    #[cfg(not(feature = "fat-guest"))]
536    #[test]
537    fn wasi_minimal_rejected_without_feature() {
538        // A manifest that asks for the fat-guest grant must fail closed on a build that cannot
539        // provide it (same rule as outbound_http/outbound_tcp).
540        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
541        assert!(
542            m.filters[0].validate().is_err(),
543            "wasi = \"minimal\" requires the fat-guest build"
544        );
545    }
546
547    #[test]
548    fn config_section_parses_as_string_map() {
549        // `[filter.config]` (ADR 000066) is an arbitrary string→string map — no feature gate,
550        // no `deny_unknown_fields`, the host never interprets it.
551        let m = Manifest::from_toml(
552            r#"
553[[filter]]
554id = "ratelimit-redis"
555source = "oci/ratelimit-redis"
556digest = "sha256:abc"
557
558[filter.config]
559on_backend_error = "deny"
560redis_host = "redis.internal"
561redis_port = "6379"
562window_seconds = "60"
563limit = "1000"
564route_tag = "api-v1"
565"#,
566        )
567        .unwrap();
568        let cfg = m.filters[0].config.as_ref().expect("config present");
569        assert_eq!(
570            cfg.get("on_backend_error").map(String::as_str),
571            Some("deny")
572        );
573        assert_eq!(
574            cfg.get("redis_host").map(String::as_str),
575            Some("redis.internal")
576        );
577        assert_eq!(cfg.len(), 6);
578    }
579
580    #[test]
581    fn config_section_absent_by_default() {
582        let m = Manifest::from_toml(
583            r#"
584[[filter]]
585id = "x"
586source = "s"
587digest = "sha256:abc"
588"#,
589        )
590        .unwrap();
591        assert_eq!(m.filters[0].config, None);
592    }
593}