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;
5use std::io::Read;
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ControlError;
11
12/// Host-side token-bucket spec for a filter's `host-ratelimit` (ADR 000026), set in the manifest
13/// as an inline table `ratelimit = { capacity = .., refill_tokens = .., refill_interval_ms = .. }`.
14/// The operator owns it, so an untrusted filter cannot supply or override its own limit (the WIT
15/// `try-acquire` carries only `key` + `cost`).
16#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
17#[serde(deny_unknown_fields)]
18pub struct RateLimitConfig {
19    /// Maximum tokens the bucket can hold.
20    pub capacity: u64,
21    /// Tokens added each refill interval.
22    pub refill_tokens: u64,
23    /// Refill interval in milliseconds (the host advances by whole intervals).
24    pub refill_interval_ms: u64,
25}
26
27/// A URL scheme an outbound allowlist entry may name (ADR 000036). Defaults to `https`.
28#[derive(
29    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
30)]
31#[serde(rename_all = "lowercase")]
32pub enum SchemeKind {
33    #[default]
34    Https,
35    Http,
36}
37
38impl SchemeKind {
39    #[cfg(feature = "outbound-http")]
40    pub(super) fn default_port(self) -> u16 {
41        match self {
42            SchemeKind::Https => 443,
43            SchemeKind::Http => 80,
44        }
45    }
46}
47
48/// One allowed outbound destination — an exact `(scheme, host, port)` triple (ADR 000036). No
49/// wildcards: the target endpoints (JWKS / introspection / ext_authz) are fixed and known.
50#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
51#[serde(deny_unknown_fields)]
52pub struct AllowDest {
53    /// Exact host — a DNS name (matched case-insensitively) or an IP literal.
54    pub host: String,
55    /// Destination port. Defaults to the scheme's default (443/80) when omitted.
56    pub port: Option<u16>,
57    /// URL scheme (default `https`).
58    #[serde(default)]
59    pub scheme: SchemeKind,
60}
61
62/// A filter's outbound HTTP policy (ADR 000036), declared as `[filter.outbound_http]`. The operator
63/// owns it — an untrusted filter cannot supply, widen, or override it (the
64/// `wasi:http/outgoing-handler` import carries only the request). Absent = the filter is lent no
65/// outbound HTTP capability.
66///
67/// `allow` is deny-by-default: only listed destinations are reachable. `allow_private` opts specific
68/// RFC1918 / ULA CIDRs past the SSRF guard's private-range block (for internal ext_authz); it never
69/// opens the always-blocked reserved floor (loopback / link-local / cloud-metadata). Timeouts and
70/// sizes are host-clamped.
71#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
72#[serde(deny_unknown_fields)]
73pub struct OutboundHttpConfig {
74    /// The exact destinations this filter may call. Must be non-empty when the section is present.
75    pub allow: Vec<AllowDest>,
76    /// Private/ULA CIDRs (e.g. `"10.1.0.0/16"`) this filter may reach despite the SSRF private-range
77    /// block. Empty (default) leaves all private space blocked.
78    #[serde(default)]
79    pub allow_private: Vec<String>,
80    /// TCP connect timeout (ms). Host default/ceiling apply when omitted/exceeded.
81    pub connect_timeout_ms: Option<u64>,
82    /// Whole-call wall-clock timeout (ms). Host default/ceiling apply when omitted/exceeded.
83    pub total_timeout_ms: Option<u64>,
84    /// Cap on the response body the host buffers back (bytes). Host default/ceiling apply.
85    pub max_response_bytes: Option<u64>,
86    /// Cap on concurrent in-flight outbound calls for this filter. Host default/ceiling apply.
87    pub max_concurrent: Option<u32>,
88}
89
90/// One allowed outbound TCP destination — an exact `(host, port)` pair (ADR 000060). Unlike HTTP's
91/// [`AllowDest`] there is no scheme and no default port: a raw TCP endpoint (Redis, memcached) has
92/// no scheme to derive one from, so the operator states the port explicitly.
93#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct TcpAllowDest {
96    /// Exact host — a DNS name (matched case-insensitively) or an IP literal.
97    pub host: String,
98    /// Destination port (required — raw TCP has no scheme default).
99    pub port: u16,
100}
101
102/// A filter's outbound TCP policy (ADR 000060), declared as `[filter.outbound_tcp]`. Lends the
103/// `wasi:sockets` TCP-connect vocabulary behind the same three checks as outbound HTTP: the
104/// deny-by-default allowlist, the SSRF guard on host-resolved addresses (name-lookup vetting +
105/// IP pin), and host-clamped resource bounds. UDP and listen are never lent.
106#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct OutboundTcpConfig {
109    /// The exact destinations this filter may connect to. Must be non-empty when present.
110    pub allow: Vec<TcpAllowDest>,
111    /// Private/ULA CIDRs (e.g. `"10.1.0.0/16"`) this filter may reach despite the SSRF
112    /// private-range block. Empty (default) leaves all private space blocked.
113    #[serde(default)]
114    pub allow_private: Vec<String>,
115    /// Per-request budget of TCP connects (a connection held across requests costs only its
116    /// opening request). Host default/ceiling apply when omitted/exceeded.
117    pub max_connections: Option<u32>,
118    /// Wall-clock ceiling (ms) on each guest hook call for this filter — the host-side I/O
119    /// deadline epoch interruption cannot provide while the guest blocks in socket I/O
120    /// (connect *and* read hangs). Host default/ceiling apply when omitted/exceeded.
121    pub io_deadline_ms: Option<u64>,
122}
123
124/// One filter to load, pinned by OCI digest.
125#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
126#[serde(deny_unknown_fields)]
127pub struct FilterEntry {
128    /// Host-assigned identity; namespaces the filter's KV (ADR 000011) and names it in chains.
129    ///
130    /// KV continuity is **per-id and survives reload** (f000004 #4): reloading the same `id`
131    /// with a new `digest` keeps the same KV namespace, so the new version inherits the old
132    /// version's bytes (good for rate-limit counters — a reload doesn't reset them). If a new
133    /// version changes its state encoding, handle the migration or use a new `id` (= a fresh
134    /// namespace).
135    pub id: String,
136    /// Manifest-relative path to the local OCI image-layout for this filter.
137    pub source: String,
138    /// Pinned OCI image-manifest digest, `sha256:...` (reproducibility / supply chain).
139    pub digest: String,
140    #[serde(default)]
141    pub isolation: IsolationKind,
142    pub init_deadline_ms: Option<u64>,
143    pub request_deadline_ms: Option<u64>,
144    pub max_memory_bytes: Option<u64>,
145    /// Trusted pool capacity (max concurrent reusable instances, ADR 000012), `pool_size`.
146    /// Only valid with `isolation = "trusted"` (the untrusted lifecycle is fresh-per-request);
147    /// the host clamps to its ceiling. Absent = the host default.
148    pub pool_size: Option<usize>,
149    /// Bounded wait (ms) for a free trusted-pool instance under saturation before failing closed
150    /// (ADR 000012), `checkout_timeout_ms`. Trusted-only, like `pool_size`; `0` = fail
151    /// immediately at saturation. Absent = the host default.
152    pub checkout_timeout_ms: Option<u64>,
153    /// Recycle a trusted instance (discard + rebuild) after this many requests, bounding
154    /// linear-memory state accumulation (ADR 000012), `max_requests_per_instance`.
155    /// Trusted-only, like `pool_size`. Absent = the host default.
156    pub max_requests_per_instance: Option<u64>,
157    /// Host-side token bucket for this filter's `host-ratelimit` (ADR 000026). Absent = the filter
158    /// has no limiter (its `try-acquire` fails closed). Operator-configured so an untrusted filter
159    /// cannot override its own limit.
160    pub ratelimit: Option<RateLimitConfig>,
161    /// This filter's outbound HTTP policy (ADR 000036), `[filter.outbound_http]`. Absent = no
162    /// outbound HTTP capability. Requires the `outbound-http` build; otherwise a present section is
163    /// rejected at validate (fail-closed).
164    #[serde(default)]
165    pub outbound_http: Option<OutboundHttpConfig>,
166    /// This filter's outbound TCP policy (ADR 000060), `[filter.outbound_tcp]`: the wasi:sockets
167    /// lend behind the same deny-by-default allowlist + SSRF floor shape as outbound HTTP. Absent =
168    /// no outbound TCP capability. Requires the `outbound-tcp` build; otherwise a present section
169    /// is rejected at validate (fail-closed).
170    #[serde(default)]
171    pub outbound_tcp: Option<OutboundTcpConfig>,
172    /// This filter's WASI grant (ADR 000063), `wasi = "none" | "minimal"`. Absent/`"none"` (the
173    /// default) keeps the filter zero-WASI (Tier A). Requires the `fat-guest` build; otherwise
174    /// `"minimal"` is rejected at validate (fail-closed).
175    #[serde(default)]
176    pub wasi: WasiKind,
177    /// This filter's business config (ADR 000066), `[filter.config]`: an arbitrary string→string
178    /// map the filter reads back via `host-config::get`. The host does not interpret keys or
179    /// values — only the filter does (e.g. Redis host/port, window seconds, `on_backend_error`).
180    /// Absent = an empty map (every `get` reads `None`). Unlike `ratelimit` / `outbound_*`, this is
181    /// a base capability with no feature gate.
182    #[serde(default)]
183    pub config: Option<BTreeMap<String, String>>,
184    /// File-indirected config values (field report §3.2), `[filter.config_files]`: the same
185    /// string→string key space as `config`, but each value is a **path** — absolute, or
186    /// relative to the manifest's directory — whose file content (UTF-8, whitespace-trimmed at
187    /// both ends, ≤ 1 MiB) is served through the same `host-config::get` keys. The shape
188    /// container secrets arrive in (`/run/secrets/<name>` mounts, credential directories):
189    /// the manifest stays a static, checked-in single source while the secret material never
190    /// appears in it. Resolved at load/reload time, so a SIGHUP picks up a rotated secret
191    /// file; symlinks are followed (orchestrator secret mounts rotate via symlink swaps). A
192    /// key set in both `config` and `config_files` is a validate/load error, and a missing or
193    /// unreadable file fails the load — same fail-closed posture as trust keys and TLS certs.
194    #[serde(default)]
195    pub config_files: Option<BTreeMap<String, String>>,
196}
197
198/// Per-file ceiling for `[filter.config_files]` reads: far beyond any sane config value, small
199/// enough that a mis-pointed path (a device file, a dump) fails fast instead of ballooning the
200/// load. Matches the magnitude credential-passing systems cap secrets at.
201const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
202
203/// Manifest spelling of the host's `Isolation`. Defaults to `untrusted` (fail-closed).
204#[derive(
205    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
206)]
207#[serde(rename_all = "lowercase")]
208pub enum IsolationKind {
209    #[default]
210    Untrusted,
211    Trusted,
212}
213
214/// A filter's WASI grant (ADR 000063). `minimal` lends the fixed, non-configurable slice —
215/// `wasi:io` / `wasi:clocks` / `wasi:random` / `wasi:cli` (no filesystem, no sockets), stdout/
216/// stderr bridged into this filter's `host-log` — for guests whose runtime assumes WASI is
217/// present (TinyGo/Go and similar "fat" guests). Unlike `outbound_http`/`outbound_tcp` there is
218/// no allowlist to declare: the grant is fixed, so this is a bare enum, not a sub-table. Requires
219/// the `fat-guest` build; otherwise `minimal` is rejected at validate (fail-closed).
220#[derive(
221    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
222)]
223#[serde(rename_all = "lowercase")]
224pub enum WasiKind {
225    #[default]
226    None,
227    Minimal,
228}
229
230impl FilterEntry {
231    /// Merge `[filter.config]` with the file-resolved `[filter.config_files]` values (field
232    /// report §3.2), or `None` when there is nothing to resolve (the inline map alone keeps
233    /// flowing through `load_options()`, ADR 000066 behaviour unchanged). Runs at every
234    /// load/reload — a SIGHUP re-reads rotated secret files — and inside `validate_manifest`,
235    /// where it joins the artifact-free fail-closed file loads (trust keys, TLS certs).
236    ///
237    /// Fail-closed rules, all surfaced as errors rather than silent fallbacks: a key set in
238    /// both maps (the `X` / `X_file` pairs convention treats them as mutually exclusive), a
239    /// missing/unreadable file, non-UTF-8 content (`host-config::get` serves `string`), and a
240    /// file past [`MAX_CONFIG_FILE_BYTES`]. Content is whitespace-trimmed at both ends — the
241    /// trailing-newline problem of `echo`-written secret files; no standard fixes a posture,
242    /// so this one is deliberate and documented. Symlinks are followed: orchestrator secret
243    /// mounts rotate atomically via symlink swaps, and the manifest (operator input) is
244    /// trusted to point where it says — unlike an artifact blob, which stays symlink-refused.
245    pub fn resolved_config(
246        &self,
247        base_dir: &Path,
248    ) -> Result<Option<BTreeMap<String, String>>, ControlError> {
249        let Some(files) = &self.config_files else {
250            return Ok(None);
251        };
252        let mut merged = self.config.clone().unwrap_or_default();
253        for (key, path) in files {
254            if merged.contains_key(key) {
255                return Err(ControlError::InvalidFilterConfig {
256                    id: self.id.clone(),
257                    reason: format!(
258                        "config key {key} is set in both [filter.config] and \
259                         [filter.config_files] — they are mutually exclusive"
260                    ),
261                });
262            }
263            // `join` on an absolute value keeps it absolute — `/run/secrets/<name>` works as
264            // written, a bare name resolves beside the manifest.
265            let full = base_dir.join(path);
266            let file = std::fs::File::open(&full).map_err(|e| ControlError::IoAt {
267                path: full.clone(),
268                source: e,
269            })?;
270            // Bounded single-open read (no metadata-then-read race): one byte past the cap is
271            // enough to detect an oversized file without buffering it.
272            let mut bytes = Vec::new();
273            file.take(MAX_CONFIG_FILE_BYTES + 1)
274                .read_to_end(&mut bytes)
275                .map_err(|e| ControlError::IoAt {
276                    path: full.clone(),
277                    source: e,
278                })?;
279            if bytes.len() as u64 > MAX_CONFIG_FILE_BYTES {
280                return Err(ControlError::InvalidFilterConfig {
281                    id: self.id.clone(),
282                    reason: format!(
283                        "config file for key {key} ({}) exceeds the {MAX_CONFIG_FILE_BYTES}-byte \
284                         cap",
285                        full.display()
286                    ),
287                });
288            }
289            let text = String::from_utf8(bytes).map_err(|_| ControlError::InvalidFilterConfig {
290                id: self.id.clone(),
291                reason: format!(
292                    "config file for key {key} ({}) is not valid UTF-8 — host-config serves \
293                     strings",
294                    full.display()
295                ),
296            })?;
297            merged.insert(key.clone(), text.trim().to_string());
298        }
299        Ok(Some(merged))
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::manifest::Manifest;
307
308    #[test]
309    fn parses_filters_and_chain_with_defaults() {
310        let m = Manifest::from_toml(
311            r#"
312[[filter]]
313id = "auth"
314source = "artifacts/auth"
315digest = "sha256:abc"
316
317[[filter]]
318id = "rl"
319source = "artifacts/rl"
320digest = "sha256:def"
321isolation = "trusted"
322request_deadline_ms = 25
323
324[chain]
325filters = ["auth", "rl"]
326"#,
327        )
328        .unwrap();
329
330        assert_eq!(m.filters.len(), 2);
331        assert_eq!(m.filters[0].isolation, IsolationKind::Untrusted); // default
332        assert_eq!(m.filters[1].isolation, IsolationKind::Trusted);
333        assert_eq!(m.filters[1].request_deadline_ms, Some(25));
334        assert_eq!(m.chain.filters, vec!["auth".to_string(), "rl".to_string()]);
335    }
336
337    const OUTBOUND_TOML: &str = r#"
338[[filter]]
339id = "extauthz"
340source = "oci/extauthz"
341digest = "sha256:abc"
342
343[filter.outbound_http]
344allow = [
345  { host = "authz.example.com", port = 8443, scheme = "https" },
346  { host = "jwks.example.com" },
347]
348allow_private = ["10.1.0.0/16"]
349connect_timeout_ms = 1500
350"#;
351
352    #[test]
353    fn outbound_section_parses() {
354        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
355        let ob = m.filters[0]
356            .outbound_http
357            .as_ref()
358            .expect("outbound_http present");
359        assert_eq!(ob.allow.len(), 2);
360        assert_eq!(ob.allow[0].host, "authz.example.com");
361        assert_eq!(ob.allow[0].port, Some(8443));
362        assert_eq!(ob.allow[0].scheme, SchemeKind::Https);
363        assert_eq!(ob.allow[1].port, None); // defaulted at lowering time
364        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
365        assert_eq!(ob.connect_timeout_ms, Some(1500));
366    }
367
368    #[cfg(feature = "outbound-http")]
369    #[test]
370    fn outbound_validates_and_lowers_to_policy() {
371        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
372        let entry = &m.filters[0];
373        entry.validate().expect("valid outbound section");
374
375        let opts = entry.load_options();
376        let policy = opts
377            .outbound_http
378            .expect("outbound_http lowered into LoadOptions");
379        assert_eq!(policy.allow.len(), 2);
380        // exact allowlist matching + scheme default port
381        assert!(policy.allows(plecto_host::Scheme::Https, "authz.example.com", 8443));
382        assert!(policy.allows(plecto_host::Scheme::Https, "jwks.example.com", 443));
383        assert!(!policy.allows(plecto_host::Scheme::Http, "authz.example.com", 8443));
384        // allow_private opt-in reaches the SSRF classifier
385        assert_eq!(
386            policy.classify("10.1.2.3".parse().unwrap()),
387            plecto_host::AddrVerdict::Allowed
388        );
389        assert_eq!(
390            policy.classify("169.254.169.254".parse().unwrap()),
391            plecto_host::AddrVerdict::BlockedReserved
392        );
393        // small value passes through; the connect timeout was 1500ms
394        assert_eq!(
395            policy.connect_timeout,
396            std::time::Duration::from_millis(1500)
397        );
398    }
399
400    #[cfg(feature = "outbound-http")]
401    #[test]
402    fn outbound_clamps_oversized_values() {
403        let toml = r#"
404[[filter]]
405id = "x"
406source = "s"
407digest = "sha256:abc"
408[filter.outbound_http]
409allow = [{ host = "a.example.com" }]
410total_timeout_ms = 999999999
411max_concurrent = 100000
412"#;
413        let m = Manifest::from_toml(toml).unwrap();
414        let policy = m.filters[0].load_options().outbound_http.unwrap();
415        assert!(policy.total_timeout <= std::time::Duration::from_secs(30));
416        assert!(policy.max_concurrent <= 64);
417    }
418
419    #[cfg(feature = "outbound-http")]
420    #[test]
421    fn outbound_rejects_bad_config() {
422        let cases = [
423            // unparseable CIDR
424            (
425                "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
426                "bad CIDR",
427            ),
428            // zero timeout
429            (
430                "allow = [{ host = \"a\" }]\nconnect_timeout_ms = 0",
431                "zero connect timeout",
432            ),
433        ];
434        for (body, why) in cases {
435            let toml = format!(
436                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_http]\n{body}\n"
437            );
438            let m = Manifest::from_toml(&toml).unwrap();
439            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
440        }
441    }
442
443    /// Empty `allow` is deny-by-default (no reachable destination) but still links wasi:http —
444    /// required by wasip2 guests that import the interface even when unused (filter-jwt static path).
445    #[cfg(feature = "outbound-http")]
446    #[test]
447    fn outbound_allows_empty_allowlist_as_deny_all() {
448        let toml = r#"
449[[filter]]
450id = "x"
451source = "s"
452digest = "sha256:abc"
453[filter.outbound_http]
454allow = []
455"#;
456        let m = Manifest::from_toml(toml).unwrap();
457        m.filters[0]
458            .validate()
459            .expect("empty allow is deny-all, not invalid");
460        let opts = m.filters[0].load_options();
461        assert!(opts.outbound_http.is_some());
462        assert!(opts.outbound_http.unwrap().allow.is_empty());
463    }
464
465    const OUTBOUND_TCP_TOML: &str = r#"
466[[filter]]
467id = "ratelimit-redis"
468source = "oci/ratelimit-redis"
469digest = "sha256:abc"
470
471[filter.outbound_tcp]
472allow = [{ host = "redis.internal", port = 6379 }]
473allow_private = ["10.1.0.0/16"]
474max_connections = 2
475io_deadline_ms = 1500
476"#;
477
478    #[test]
479    fn outbound_tcp_section_parses() {
480        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
481        let ob = m.filters[0]
482            .outbound_tcp
483            .as_ref()
484            .expect("outbound_tcp present");
485        assert_eq!(ob.allow.len(), 1);
486        assert_eq!(ob.allow[0].host, "redis.internal");
487        assert_eq!(ob.allow[0].port, 6379);
488        assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
489        assert_eq!(ob.max_connections, Some(2));
490        assert_eq!(ob.io_deadline_ms, Some(1500));
491    }
492
493    #[test]
494    fn outbound_tcp_port_is_required() {
495        // No scheme → no default port; an entry without one must fail to parse, not silently
496        // default.
497        let toml = r#"
498[[filter]]
499id = "x"
500source = "s"
501digest = "sha256:abc"
502[filter.outbound_tcp]
503allow = [{ host = "redis.internal" }]
504"#;
505        assert!(Manifest::from_toml(toml).is_err(), "port is mandatory");
506    }
507
508    #[cfg(feature = "outbound-tcp")]
509    #[test]
510    fn outbound_tcp_validates_and_lowers_to_policy() {
511        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
512        let entry = &m.filters[0];
513        entry.validate().expect("valid outbound_tcp section");
514
515        let opts = entry.load_options();
516        let policy = opts
517            .outbound_tcp
518            .expect("outbound_tcp lowered into LoadOptions");
519        assert_eq!(policy.allow.len(), 1);
520        assert!(policy.allows_name("REDIS.internal")); // DNS case-insensitive
521        assert!(!policy.allows_name("evil.internal"));
522        // allow_private opt-in reaches the shared SSRF classifier; the floor stays closed
523        assert_eq!(
524            policy.classify("10.1.2.3".parse().unwrap()),
525            plecto_host::AddrVerdict::Allowed
526        );
527        assert_eq!(
528            policy.classify("169.254.169.254".parse().unwrap()),
529            plecto_host::AddrVerdict::BlockedReserved
530        );
531        assert_eq!(policy.max_connections, 2);
532        assert_eq!(policy.io_deadline, std::time::Duration::from_millis(1500));
533    }
534
535    #[cfg(feature = "outbound-tcp")]
536    #[test]
537    fn outbound_tcp_clamps_oversized_values() {
538        let toml = r#"
539[[filter]]
540id = "x"
541source = "s"
542digest = "sha256:abc"
543[filter.outbound_tcp]
544allow = [{ host = "redis.internal", port = 6379 }]
545max_connections = 100000
546io_deadline_ms = 999999999
547"#;
548        let m = Manifest::from_toml(toml).unwrap();
549        let policy = m.filters[0].load_options().outbound_tcp.unwrap();
550        assert!(policy.max_connections <= 64);
551        assert!(policy.io_deadline <= std::time::Duration::from_secs(30));
552    }
553
554    #[cfg(feature = "outbound-tcp")]
555    #[test]
556    fn outbound_tcp_rejects_bad_config() {
557        let cases = [
558            ("allow = []", "empty allowlist"),
559            ("allow = [{ host = \"a\", port = 0 }]", "port 0"),
560            (
561                "allow = [{ host = \"a\", port = 6379 }]\nallow_private = [\"not-a-cidr\"]",
562                "bad CIDR",
563            ),
564            (
565                "allow = [{ host = \"a\", port = 6379 }]\nmax_connections = 0",
566                "zero connect budget",
567            ),
568            (
569                "allow = [{ host = \"a\", port = 6379 }]\nio_deadline_ms = 0",
570                "zero io deadline",
571            ),
572        ];
573        for (body, why) in cases {
574            let toml = format!(
575                "[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_tcp]\n{body}\n"
576            );
577            let m = Manifest::from_toml(&toml).unwrap();
578            assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
579        }
580    }
581
582    #[cfg(not(feature = "outbound-tcp"))]
583    #[test]
584    fn outbound_tcp_rejected_without_feature() {
585        // A manifest that asks for outbound TCP must fail closed on a build that cannot provide it.
586        let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
587        assert!(
588            m.filters[0].validate().is_err(),
589            "outbound_tcp requires the outbound-tcp build"
590        );
591    }
592
593    #[cfg(not(feature = "outbound-http"))]
594    #[test]
595    fn outbound_rejected_without_feature() {
596        // A manifest that asks for outbound must fail closed on a build that cannot provide it.
597        let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
598        assert!(
599            m.filters[0].validate().is_err(),
600            "outbound_http requires the outbound-http build"
601        );
602    }
603
604    const WASI_MINIMAL_TOML: &str = r#"
605[[filter]]
606id = "hello-go"
607source = "oci/hello-go"
608digest = "sha256:abc"
609wasi = "minimal"
610"#;
611
612    #[test]
613    fn wasi_defaults_to_none() {
614        let m = Manifest::from_toml(
615            r#"
616[[filter]]
617id = "x"
618source = "s"
619digest = "sha256:abc"
620"#,
621        )
622        .unwrap();
623        assert_eq!(m.filters[0].wasi, WasiKind::None);
624    }
625
626    #[test]
627    fn wasi_minimal_parses() {
628        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
629        assert_eq!(m.filters[0].wasi, WasiKind::Minimal);
630    }
631
632    #[cfg(feature = "fat-guest")]
633    #[test]
634    fn wasi_minimal_validates_and_lowers_to_load_options() {
635        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
636        let entry = &m.filters[0];
637        entry.validate().expect("valid wasi = \"minimal\" section");
638        assert!(entry.load_options().wasi_minimal);
639    }
640
641    #[cfg(not(feature = "fat-guest"))]
642    #[test]
643    fn wasi_minimal_rejected_without_feature() {
644        // A manifest that asks for the fat-guest grant must fail closed on a build that cannot
645        // provide it (same rule as outbound_http/outbound_tcp).
646        let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
647        assert!(
648            m.filters[0].validate().is_err(),
649            "wasi = \"minimal\" requires the fat-guest build"
650        );
651    }
652
653    #[test]
654    fn config_section_parses_as_string_map() {
655        // `[filter.config]` (ADR 000066) is an arbitrary string→string map — no feature gate,
656        // no `deny_unknown_fields`, the host never interprets it.
657        let m = Manifest::from_toml(
658            r#"
659[[filter]]
660id = "ratelimit-redis"
661source = "oci/ratelimit-redis"
662digest = "sha256:abc"
663
664[filter.config]
665on_backend_error = "deny"
666redis_host = "redis.internal"
667redis_port = "6379"
668window_seconds = "60"
669limit = "1000"
670route_tag = "api-v1"
671"#,
672        )
673        .unwrap();
674        let cfg = m.filters[0].config.as_ref().expect("config present");
675        assert_eq!(
676            cfg.get("on_backend_error").map(String::as_str),
677            Some("deny")
678        );
679        assert_eq!(
680            cfg.get("redis_host").map(String::as_str),
681            Some("redis.internal")
682        );
683        assert_eq!(cfg.len(), 6);
684    }
685
686    #[test]
687    fn config_files_resolve_into_the_config_map_trimmed() {
688        // `[filter.config_files]` (field report §3.2): container secrets arrive as files
689        // (`/run/secrets/...`), so each value is a path — absolute, or relative to the
690        // manifest's directory — whose file content becomes the value served through the
691        // existing `host-config::get` key space. Content is whitespace-trimmed at both ends
692        // (the `echo`-appended trailing newline problem; the `*_file` sibling-key convention's
693        // usual posture) — a deliberate, documented choice since no standard fixes one.
694        let dir = tempfile::tempdir().unwrap();
695        std::fs::write(dir.path().join("hmac_key"), "s3cret-value\n").unwrap();
696        let m = Manifest::from_toml(
697            r#"
698[[filter]]
699id = "session-auth"
700source = "oci/session-auth"
701digest = "sha256:abc"
702
703[filter.config]
704cookie_name = "session"
705
706[filter.config_files]
707hmac_key = "hmac_key"
708"#,
709        )
710        .unwrap();
711        let cfg = m.filters[0]
712            .resolved_config(dir.path())
713            .unwrap()
714            .expect("config_files present resolves to a merged map");
715        assert_eq!(
716            cfg.get("hmac_key").map(String::as_str),
717            Some("s3cret-value")
718        );
719        assert_eq!(cfg.get("cookie_name").map(String::as_str), Some("session"));
720    }
721
722    #[test]
723    fn config_files_reject_a_key_collision_with_config() {
724        // `X` and `X_file`-style pairs are conventionally mutually exclusive — both set is an
725        // operator mistake, surfaced as an error rather than a silent precedence rule.
726        let dir = tempfile::tempdir().unwrap();
727        std::fs::write(dir.path().join("hmac_key"), "x").unwrap();
728        let m = Manifest::from_toml(
729            r#"
730[[filter]]
731id = "session-auth"
732source = "oci/session-auth"
733digest = "sha256:abc"
734
735[filter.config]
736hmac_key = "inline"
737
738[filter.config_files]
739hmac_key = "hmac_key"
740"#,
741        )
742        .unwrap();
743        let err = m.filters[0].resolved_config(dir.path()).unwrap_err();
744        assert!(
745            err.to_string().contains("hmac_key"),
746            "the collision names the key, got: {err}"
747        );
748    }
749
750    #[test]
751    fn config_files_fail_closed_on_a_missing_file() {
752        // Same posture as a missing trust key or TLS cert: a secret that cannot be read is a
753        // load error, never a silently absent config key.
754        let dir = tempfile::tempdir().unwrap();
755        let m = Manifest::from_toml(
756            r#"
757[[filter]]
758id = "session-auth"
759source = "oci/session-auth"
760digest = "sha256:abc"
761
762[filter.config_files]
763hmac_key = "no-such-file"
764"#,
765        )
766        .unwrap();
767        assert!(m.filters[0].resolved_config(dir.path()).is_err());
768    }
769
770    #[test]
771    fn config_files_fail_closed_on_non_utf8_and_oversize() {
772        // `host-config::get` serves `string`, so the file must decode as UTF-8; and a config
773        // value has no business being megabytes — both are load errors, fail-closed.
774        let dir = tempfile::tempdir().unwrap();
775        std::fs::write(dir.path().join("binary"), [0xFFu8, 0xFE, 0x00]).unwrap();
776        std::fs::write(dir.path().join("huge"), vec![b'a'; 1024 * 1024 + 1]).unwrap();
777        for file in ["binary", "huge"] {
778            let m = Manifest::from_toml(&format!(
779                r#"
780[[filter]]
781id = "session-auth"
782source = "oci/session-auth"
783digest = "sha256:abc"
784
785[filter.config_files]
786hmac_key = "{file}"
787"#
788            ))
789            .unwrap();
790            assert!(
791                m.filters[0].resolved_config(dir.path()).is_err(),
792                "{file} must be rejected"
793            );
794        }
795    }
796
797    #[test]
798    fn resolved_config_is_none_without_config_files() {
799        // No `[filter.config_files]` → nothing to resolve; the inline `[filter.config]` keeps
800        // flowing through `load_options()` untouched (ADR 000066 behaviour, unchanged).
801        let m = Manifest::from_toml(
802            r#"
803[[filter]]
804id = "x"
805source = "s"
806digest = "sha256:abc"
807
808[filter.config]
809k = "v"
810"#,
811        )
812        .unwrap();
813        assert!(
814            m.filters[0]
815                .resolved_config(Path::new("."))
816                .unwrap()
817                .is_none()
818        );
819    }
820
821    #[test]
822    fn config_section_absent_by_default() {
823        let m = Manifest::from_toml(
824            r#"
825[[filter]]
826id = "x"
827source = "s"
828digest = "sha256:abc"
829"#,
830        )
831        .unwrap();
832        assert_eq!(m.filters[0].config, None);
833    }
834}