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 ratelimit: Option<RateLimitConfig>,
145 #[serde(default)]
149 pub outbound_http: Option<OutboundHttpConfig>,
150 #[serde(default)]
155 pub outbound_tcp: Option<OutboundTcpConfig>,
156 #[serde(default)]
160 pub wasi: WasiKind,
161 #[serde(default)]
167 pub config: Option<BTreeMap<String, String>>,
168}
169
170#[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#[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); 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); 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 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 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 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 (
319 "allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
320 "bad CIDR",
321 ),
322 (
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 #[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 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")); assert!(!policy.allows_name("evil.internal"));
416 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 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 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 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 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}