1use std::collections::BTreeMap;
5use std::io::Read;
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ControlError;
11
12#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
17#[serde(deny_unknown_fields)]
18pub struct RateLimitConfig {
19 pub capacity: u64,
21 pub refill_tokens: u64,
23 pub refill_interval_ms: u64,
25}
26
27#[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#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
51#[serde(deny_unknown_fields)]
52pub struct AllowDest {
53 pub host: String,
55 pub port: Option<u16>,
57 #[serde(default)]
59 pub scheme: SchemeKind,
60}
61
62#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
72#[serde(deny_unknown_fields)]
73pub struct OutboundHttpConfig {
74 pub allow: Vec<AllowDest>,
76 #[serde(default)]
79 pub allow_private: Vec<String>,
80 pub connect_timeout_ms: Option<u64>,
82 pub total_timeout_ms: Option<u64>,
84 pub max_response_bytes: Option<u64>,
86 pub max_concurrent: Option<u32>,
88}
89
90#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct TcpAllowDest {
96 pub host: String,
98 pub port: u16,
100}
101
102#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct OutboundTcpConfig {
109 pub allow: Vec<TcpAllowDest>,
111 #[serde(default)]
114 pub allow_private: Vec<String>,
115 pub max_connections: Option<u32>,
118 pub io_deadline_ms: Option<u64>,
122}
123
124#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
126#[serde(deny_unknown_fields)]
127pub struct FilterEntry {
128 pub id: String,
136 pub source: String,
138 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 pub pool_size: Option<usize>,
149 pub checkout_timeout_ms: Option<u64>,
153 pub max_requests_per_instance: Option<u64>,
157 pub ratelimit: Option<RateLimitConfig>,
161 #[serde(default)]
165 pub outbound_http: Option<OutboundHttpConfig>,
166 #[serde(default)]
171 pub outbound_tcp: Option<OutboundTcpConfig>,
172 #[serde(default)]
176 pub wasi: WasiKind,
177 #[serde(default)]
183 pub config: Option<BTreeMap<String, String>>,
184 #[serde(default)]
195 pub config_files: Option<BTreeMap<String, String>>,
196}
197
198const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
202
203#[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#[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 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 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 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); 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); 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 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 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 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 (
425 "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
426 "bad CIDR",
427 ),
428 (
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 #[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 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")); assert!(!policy.allows_name("evil.internal"));
522 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 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 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 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 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 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 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 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 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 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}