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    /// Trusted pool capacity (max concurrent reusable instances, ADR 000012), `pool_size`.
142    /// Only valid with `isolation = "trusted"` (the untrusted lifecycle is fresh-per-request);
143    /// the host clamps to its ceiling. Absent = the host default.
144    pub pool_size: Option<usize>,
145    /// Bounded wait (ms) for a free trusted-pool instance under saturation before failing closed
146    /// (ADR 000012), `checkout_timeout_ms`. Trusted-only, like `pool_size`; `0` = fail
147    /// immediately at saturation. Absent = the host default.
148    pub checkout_timeout_ms: Option<u64>,
149    /// Recycle a trusted instance (discard + rebuild) after this many requests, bounding
150    /// linear-memory state accumulation (ADR 000012), `max_requests_per_instance`.
151    /// Trusted-only, like `pool_size`. Absent = the host default.
152    pub max_requests_per_instance: Option<u64>,
153    /// Host-side token bucket for this filter's `host-ratelimit` (ADR 000026). Absent = the filter
154    /// has no limiter (its `try-acquire` fails closed). Operator-configured so an untrusted filter
155    /// cannot override its own limit.
156    pub ratelimit: Option<RateLimitConfig>,
157    /// This filter's outbound HTTP policy (ADR 000036), `[filter.outbound_http]`. Absent = no
158    /// outbound HTTP capability. Requires the `outbound-http` build; otherwise a present section is
159    /// rejected at validate (fail-closed).
160    #[serde(default)]
161    pub outbound_http: Option<OutboundHttpConfig>,
162    /// This filter's outbound TCP policy (ADR 000060), `[filter.outbound_tcp]`: the wasi:sockets
163    /// lend behind the same deny-by-default allowlist + SSRF floor shape as outbound HTTP. Absent =
164    /// no outbound TCP capability. Requires the `outbound-tcp` build; otherwise a present section
165    /// is rejected at validate (fail-closed).
166    #[serde(default)]
167    pub outbound_tcp: Option<OutboundTcpConfig>,
168    /// This filter's WASI grant (ADR 000063), `wasi = "none" | "minimal"`. Absent/`"none"` (the
169    /// default) keeps the filter zero-WASI (Tier A). Requires the `fat-guest` build; otherwise
170    /// `"minimal"` is rejected at validate (fail-closed).
171    #[serde(default)]
172    pub wasi: WasiKind,
173    /// This filter's business config (ADR 000066), `[filter.config]`: an arbitrary string→string
174    /// map the filter reads back via `host-config::get`. The host does not interpret keys or
175    /// values — only the filter does (e.g. Redis host/port, window seconds, `on_backend_error`).
176    /// Absent = an empty map (every `get` reads `None`). Unlike `ratelimit` / `outbound_*`, this is
177    /// a base capability with no feature gate.
178    #[serde(default)]
179    pub config: Option<BTreeMap<String, String>>,
180}
181
182/// Manifest spelling of the host's `Isolation`. Defaults to `untrusted` (fail-closed).
183#[derive(
184    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
185)]
186#[serde(rename_all = "lowercase")]
187pub enum IsolationKind {
188    #[default]
189    Untrusted,
190    Trusted,
191}
192
193/// A filter's WASI grant (ADR 000063). `minimal` lends the fixed, non-configurable slice —
194/// `wasi:io` / `wasi:clocks` / `wasi:random` / `wasi:cli` (no filesystem, no sockets), stdout/
195/// stderr bridged into this filter's `host-log` — for guests whose runtime assumes WASI is
196/// present (TinyGo/Go and similar "fat" guests). Unlike `outbound_http`/`outbound_tcp` there is
197/// no allowlist to declare: the grant is fixed, so this is a bare enum, not a sub-table. Requires
198/// the `fat-guest` build; otherwise `minimal` is rejected at validate (fail-closed).
199#[derive(
200    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
201)]
202#[serde(rename_all = "lowercase")]
203pub enum WasiKind {
204    #[default]
205    None,
206    Minimal,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::manifest::Manifest;
213
214    #[test]
215    fn parses_filters_and_chain_with_defaults() {
216        let m = Manifest::from_toml(
217            r#"
218[[filter]]
219id = "auth"
220source = "artifacts/auth"
221digest = "sha256:abc"
222
223[[filter]]
224id = "rl"
225source = "artifacts/rl"
226digest = "sha256:def"
227isolation = "trusted"
228request_deadline_ms = 25
229
230[chain]
231filters = ["auth", "rl"]
232"#,
233        )
234        .unwrap();
235
236        assert_eq!(m.filters.len(), 2);
237        assert_eq!(m.filters[0].isolation, IsolationKind::Untrusted); // default
238        assert_eq!(m.filters[1].isolation, IsolationKind::Trusted);
239        assert_eq!(m.filters[1].request_deadline_ms, Some(25));
240        assert_eq!(m.chain.filters, vec!["auth".to_string(), "rl".to_string()]);
241    }
242
243    const OUTBOUND_TOML: &str = r#"
244[[filter]]
245id = "extauthz"
246source = "oci/extauthz"
247digest = "sha256:abc"
248
249[filter.outbound_http]
250allow = [
251  { host = "authz.example.com", port = 8443, scheme = "https" },
252  { host = "jwks.example.com" },
253]
254allow_private = ["10.1.0.0/16"]
255connect_timeout_ms = 1500
256"#;
257
258    #[test]
259    fn outbound_section_parses() {
260        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
261        let ob = m.filters[0]
262            .outbound_http
263            .as_ref()
264            .expect("outbound_http present");
265        assert_eq!(ob.allow.len(), 2);
266        assert_eq!(ob.allow[0].host, "authz.example.com");
267        assert_eq!(ob.allow[0].port, Some(8443));
268        assert_eq!(ob.allow[0].scheme, SchemeKind::Https);
269        assert_eq!(ob.allow[1].port, None); // defaulted at lowering time
270        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
271        assert_eq!(ob.connect_timeout_ms, Some(1500));
272    }
273
274    #[cfg(feature = "outbound-http")]
275    #[test]
276    fn outbound_validates_and_lowers_to_policy() {
277        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
278        let entry = &m.filters[0];
279        entry.validate().expect("valid outbound section");
280
281        let opts = entry.load_options();
282        let policy = opts
283            .outbound_http
284            .expect("outbound_http lowered into LoadOptions");
285        assert_eq!(policy.allow.len(), 2);
286        // exact allowlist matching + scheme default port
287        assert!(policy.allows(plecto_host::Scheme::Https, "authz.example.com", 8443));
288        assert!(policy.allows(plecto_host::Scheme::Https, "jwks.example.com", 443));
289        assert!(!policy.allows(plecto_host::Scheme::Http, "authz.example.com", 8443));
290        // allow_private opt-in reaches the SSRF classifier
291        assert_eq!(
292            policy.classify("10.1.2.3".parse().unwrap()),
293            plecto_host::AddrVerdict::Allowed
294        );
295        assert_eq!(
296            policy.classify("169.254.169.254".parse().unwrap()),
297            plecto_host::AddrVerdict::BlockedReserved
298        );
299        // small value passes through; the connect timeout was 1500ms
300        assert_eq!(
301            policy.connect_timeout,
302            std::time::Duration::from_millis(1500)
303        );
304    }
305
306    #[cfg(feature = "outbound-http")]
307    #[test]
308    fn outbound_clamps_oversized_values() {
309        let toml = r#"
310[[filter]]
311id = "x"
312source = "s"
313digest = "sha256:abc"
314[filter.outbound_http]
315allow = [{ host = "a.example.com" }]
316total_timeout_ms = 999999999
317max_concurrent = 100000
318"#;
319        let m = Manifest::from_toml(toml).unwrap();
320        let policy = m.filters[0].load_options().outbound_http.unwrap();
321        assert!(policy.total_timeout <= std::time::Duration::from_secs(30));
322        assert!(policy.max_concurrent <= 64);
323    }
324
325    #[cfg(feature = "outbound-http")]
326    #[test]
327    fn outbound_rejects_bad_config() {
328        let cases = [
329            // unparseable CIDR
330            (
331                "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
332                "bad CIDR",
333            ),
334            // zero timeout
335            (
336                "allow = [{ host = \"a\" }]\nconnect_timeout_ms = 0",
337                "zero connect timeout",
338            ),
339        ];
340        for (body, why) in cases {
341            let toml = format!(
342                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_http]\n{body}\n"
343            );
344            let m = Manifest::from_toml(&toml).unwrap();
345            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
346        }
347    }
348
349    /// Empty `allow` is deny-by-default (no reachable destination) but still links wasi:http —
350    /// required by wasip2 guests that import the interface even when unused (filter-jwt static path).
351    #[cfg(feature = "outbound-http")]
352    #[test]
353    fn outbound_allows_empty_allowlist_as_deny_all() {
354        let toml = r#"
355[[filter]]
356id = "x"
357source = "s"
358digest = "sha256:abc"
359[filter.outbound_http]
360allow = []
361"#;
362        let m = Manifest::from_toml(toml).unwrap();
363        m.filters[0]
364            .validate()
365            .expect("empty allow is deny-all, not invalid");
366        let opts = m.filters[0].load_options();
367        assert!(opts.outbound_http.is_some());
368        assert!(opts.outbound_http.unwrap().allow.is_empty());
369    }
370
371    const OUTBOUND_TCP_TOML: &str = r#"
372[[filter]]
373id = "ratelimit-redis"
374source = "oci/ratelimit-redis"
375digest = "sha256:abc"
376
377[filter.outbound_tcp]
378allow = [{ host = "redis.internal", port = 6379 }]
379allow_private = ["10.1.0.0/16"]
380max_connections = 2
381io_deadline_ms = 1500
382"#;
383
384    #[test]
385    fn outbound_tcp_section_parses() {
386        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
387        let ob = m.filters[0]
388            .outbound_tcp
389            .as_ref()
390            .expect("outbound_tcp present");
391        assert_eq!(ob.allow.len(), 1);
392        assert_eq!(ob.allow[0].host, "redis.internal");
393        assert_eq!(ob.allow[0].port, 6379);
394        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
395        assert_eq!(ob.max_connections, Some(2));
396        assert_eq!(ob.io_deadline_ms, Some(1500));
397    }
398
399    #[test]
400    fn outbound_tcp_port_is_required() {
401        // No scheme → no default port; an entry without one must fail to parse, not silently
402        // default.
403        let toml = r#"
404[[filter]]
405id = "x"
406source = "s"
407digest = "sha256:abc"
408[filter.outbound_tcp]
409allow = [{ host = "redis.internal" }]
410"#;
411        assert!(Manifest::from_toml(toml).is_err(), "port is mandatory");
412    }
413
414    #[cfg(feature = "outbound-tcp")]
415    #[test]
416    fn outbound_tcp_validates_and_lowers_to_policy() {
417        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
418        let entry = &m.filters[0];
419        entry.validate().expect("valid outbound_tcp section");
420
421        let opts = entry.load_options();
422        let policy = opts
423            .outbound_tcp
424            .expect("outbound_tcp lowered into LoadOptions");
425        assert_eq!(policy.allow.len(), 1);
426        assert!(policy.allows_name("REDIS.internal")); // DNS case-insensitive
427        assert!(!policy.allows_name("evil.internal"));
428        // allow_private opt-in reaches the shared SSRF classifier; the floor stays closed
429        assert_eq!(
430            policy.classify("10.1.2.3".parse().unwrap()),
431            plecto_host::AddrVerdict::Allowed
432        );
433        assert_eq!(
434            policy.classify("169.254.169.254".parse().unwrap()),
435            plecto_host::AddrVerdict::BlockedReserved
436        );
437        assert_eq!(policy.max_connections, 2);
438        assert_eq!(policy.io_deadline, std::time::Duration::from_millis(1500));
439    }
440
441    #[cfg(feature = "outbound-tcp")]
442    #[test]
443    fn outbound_tcp_clamps_oversized_values() {
444        let toml = r#"
445[[filter]]
446id = "x"
447source = "s"
448digest = "sha256:abc"
449[filter.outbound_tcp]
450allow = [{ host = "redis.internal", port = 6379 }]
451max_connections = 100000
452io_deadline_ms = 999999999
453"#;
454        let m = Manifest::from_toml(toml).unwrap();
455        let policy = m.filters[0].load_options().outbound_tcp.unwrap();
456        assert!(policy.max_connections <= 64);
457        assert!(policy.io_deadline <= std::time::Duration::from_secs(30));
458    }
459
460    #[cfg(feature = "outbound-tcp")]
461    #[test]
462    fn outbound_tcp_rejects_bad_config() {
463        let cases = [
464            ("allow = []", "empty allowlist"),
465            ("allow = [{ host = \"a\", port = 0 }]", "port 0"),
466            (
467                "allow = [{ host = \"a\", port = 6379 }]\nallow_private = [\"not-a-cidr\"]",
468                "bad CIDR",
469            ),
470            (
471                "allow = [{ host = \"a\", port = 6379 }]\nmax_connections = 0",
472                "zero connect budget",
473            ),
474            (
475                "allow = [{ host = \"a\", port = 6379 }]\nio_deadline_ms = 0",
476                "zero io deadline",
477            ),
478        ];
479        for (body, why) in cases {
480            let toml = format!(
481                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_tcp]\n{body}\n"
482            );
483            let m = Manifest::from_toml(&toml).unwrap();
484            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
485        }
486    }
487
488    #[cfg(not(feature = "outbound-tcp"))]
489    #[test]
490    fn outbound_tcp_rejected_without_feature() {
491        // A manifest that asks for outbound TCP must fail closed on a build that cannot provide it.
492        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
493        assert!(
494            m.filters[0].validate().is_err(),
495            "outbound_tcp requires the outbound-tcp build"
496        );
497    }
498
499    #[cfg(not(feature = "outbound-http"))]
500    #[test]
501    fn outbound_rejected_without_feature() {
502        // A manifest that asks for outbound must fail closed on a build that cannot provide it.
503        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
504        assert!(
505            m.filters[0].validate().is_err(),
506            "outbound_http requires the outbound-http build"
507        );
508    }
509
510    const WASI_MINIMAL_TOML: &str = r#"
511[[filter]]
512id = "hello-go"
513source = "oci/hello-go"
514digest = "sha256:abc"
515wasi = "minimal"
516"#;
517
518    #[test]
519    fn wasi_defaults_to_none() {
520        let m = Manifest::from_toml(
521            r#"
522[[filter]]
523id = "x"
524source = "s"
525digest = "sha256:abc"
526"#,
527        )
528        .unwrap();
529        assert_eq!(m.filters[0].wasi, WasiKind::None);
530    }
531
532    #[test]
533    fn wasi_minimal_parses() {
534        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
535        assert_eq!(m.filters[0].wasi, WasiKind::Minimal);
536    }
537
538    #[cfg(feature = "fat-guest")]
539    #[test]
540    fn wasi_minimal_validates_and_lowers_to_load_options() {
541        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
542        let entry = &m.filters[0];
543        entry.validate().expect("valid wasi = \"minimal\" section");
544        assert!(entry.load_options().wasi_minimal);
545    }
546
547    #[cfg(not(feature = "fat-guest"))]
548    #[test]
549    fn wasi_minimal_rejected_without_feature() {
550        // A manifest that asks for the fat-guest grant must fail closed on a build that cannot
551        // provide it (same rule as outbound_http/outbound_tcp).
552        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
553        assert!(
554            m.filters[0].validate().is_err(),
555            "wasi = \"minimal\" requires the fat-guest build"
556        );
557    }
558
559    #[test]
560    fn config_section_parses_as_string_map() {
561        // `[filter.config]` (ADR 000066) is an arbitrary string→string map — no feature gate,
562        // no `deny_unknown_fields`, the host never interprets it.
563        let m = Manifest::from_toml(
564            r#"
565[[filter]]
566id = "ratelimit-redis"
567source = "oci/ratelimit-redis"
568digest = "sha256:abc"
569
570[filter.config]
571on_backend_error = "deny"
572redis_host = "redis.internal"
573redis_port = "6379"
574window_seconds = "60"
575limit = "1000"
576route_tag = "api-v1"
577"#,
578        )
579        .unwrap();
580        let cfg = m.filters[0].config.as_ref().expect("config present");
581        assert_eq!(
582            cfg.get("on_backend_error").map(String::as_str),
583            Some("deny")
584        );
585        assert_eq!(
586            cfg.get("redis_host").map(String::as_str),
587            Some("redis.internal")
588        );
589        assert_eq!(cfg.len(), 6);
590    }
591
592    #[test]
593    fn config_section_absent_by_default() {
594        let m = Manifest::from_toml(
595            r#"
596[[filter]]
597id = "x"
598source = "s"
599digest = "sha256:abc"
600"#,
601        )
602        .unwrap();
603        assert_eq!(m.filters[0].config, None);
604    }
605}