1use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
13#[serde(deny_unknown_fields)]
14pub struct RateLimitConfig {
15 pub capacity: u64,
17 pub refill_tokens: u64,
19 pub refill_interval_ms: u64,
21}
22
23#[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#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
47#[serde(deny_unknown_fields)]
48pub struct AllowDest {
49 pub host: String,
51 pub port: Option<u16>,
53 #[serde(default)]
55 pub scheme: SchemeKind,
56}
57
58#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
68#[serde(deny_unknown_fields)]
69pub struct OutboundHttpConfig {
70 pub allow: Vec<AllowDest>,
72 #[serde(default)]
75 pub allow_private: Vec<String>,
76 pub connect_timeout_ms: Option<u64>,
78 pub total_timeout_ms: Option<u64>,
80 pub max_response_bytes: Option<u64>,
82 pub max_concurrent: Option<u32>,
84}
85
86#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
90#[serde(deny_unknown_fields)]
91pub struct TcpAllowDest {
92 pub host: String,
94 pub port: u16,
96}
97
98#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct OutboundTcpConfig {
105 pub allow: Vec<TcpAllowDest>,
107 #[serde(default)]
110 pub allow_private: Vec<String>,
111 pub max_connections: Option<u32>,
114 pub io_deadline_ms: Option<u64>,
118}
119
120#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
122#[serde(deny_unknown_fields)]
123pub struct FilterEntry {
124 pub id: String,
132 pub source: String,
134 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 pub pool_size: Option<usize>,
145 pub checkout_timeout_ms: Option<u64>,
149 pub max_requests_per_instance: Option<u64>,
153 pub ratelimit: Option<RateLimitConfig>,
157 #[serde(default)]
161 pub outbound_http: Option<OutboundHttpConfig>,
162 #[serde(default)]
167 pub outbound_tcp: Option<OutboundTcpConfig>,
168 #[serde(default)]
172 pub wasi: WasiKind,
173 #[serde(default)]
179 pub config: Option<BTreeMap<String, String>>,
180}
181
182#[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#[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); 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); 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 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 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 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 (
331 "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
332 "bad CIDR",
333 ),
334 (
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 #[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 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")); assert!(!policy.allows_name("evil.internal"));
428 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 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 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 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 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}