use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RateLimitConfig {
pub capacity: u64,
pub refill_tokens: u64,
pub refill_interval_ms: u64,
}
#[derive(
Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
)]
#[serde(rename_all = "lowercase")]
pub enum SchemeKind {
#[default]
Https,
Http,
}
impl SchemeKind {
#[cfg(feature = "outbound-http")]
pub(super) fn default_port(self) -> u16 {
match self {
SchemeKind::Https => 443,
SchemeKind::Http => 80,
}
}
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AllowDest {
pub host: String,
pub port: Option<u16>,
#[serde(default)]
pub scheme: SchemeKind,
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OutboundHttpConfig {
pub allow: Vec<AllowDest>,
#[serde(default)]
pub allow_private: Vec<String>,
pub connect_timeout_ms: Option<u64>,
pub total_timeout_ms: Option<u64>,
pub max_response_bytes: Option<u64>,
pub max_concurrent: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TcpAllowDest {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OutboundTcpConfig {
pub allow: Vec<TcpAllowDest>,
#[serde(default)]
pub allow_private: Vec<String>,
pub max_connections: Option<u32>,
pub io_deadline_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FilterEntry {
pub id: String,
pub source: String,
pub digest: String,
#[serde(default)]
pub isolation: IsolationKind,
pub init_deadline_ms: Option<u64>,
pub request_deadline_ms: Option<u64>,
pub max_memory_bytes: Option<u64>,
pub ratelimit: Option<RateLimitConfig>,
#[serde(default)]
pub outbound_http: Option<OutboundHttpConfig>,
#[serde(default)]
pub outbound_tcp: Option<OutboundTcpConfig>,
#[serde(default)]
pub wasi: WasiKind,
#[serde(default)]
pub config: Option<BTreeMap<String, String>>,
}
#[derive(
Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
)]
#[serde(rename_all = "lowercase")]
pub enum IsolationKind {
#[default]
Untrusted,
Trusted,
}
#[derive(
Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
)]
#[serde(rename_all = "lowercase")]
pub enum WasiKind {
#[default]
None,
Minimal,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn parses_filters_and_chain_with_defaults() {
let m = Manifest::from_toml(
r#"
[[filter]]
id = "auth"
source = "artifacts/auth"
digest = "sha256:abc"
[[filter]]
id = "rl"
source = "artifacts/rl"
digest = "sha256:def"
isolation = "trusted"
request_deadline_ms = 25
[chain]
filters = ["auth", "rl"]
"#,
)
.unwrap();
assert_eq!(m.filters.len(), 2);
assert_eq!(m.filters[0].isolation, IsolationKind::Untrusted); assert_eq!(m.filters[1].isolation, IsolationKind::Trusted);
assert_eq!(m.filters[1].request_deadline_ms, Some(25));
assert_eq!(m.chain.filters, vec!["auth".to_string(), "rl".to_string()]);
}
const OUTBOUND_TOML: &str = r#"
[[filter]]
id = "extauthz"
source = "oci/extauthz"
digest = "sha256:abc"
[filter.outbound_http]
allow = [
{ host = "authz.example.com", port = 8443, scheme = "https" },
{ host = "jwks.example.com" },
]
allow_private = ["10.1.0.0/16"]
connect_timeout_ms = 1500
"#;
#[test]
fn outbound_section_parses() {
let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
let ob = m.filters[0]
.outbound_http
.as_ref()
.expect("outbound_http present");
assert_eq!(ob.allow.len(), 2);
assert_eq!(ob.allow[0].host, "authz.example.com");
assert_eq!(ob.allow[0].port, Some(8443));
assert_eq!(ob.allow[0].scheme, SchemeKind::Https);
assert_eq!(ob.allow[1].port, None); assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
assert_eq!(ob.connect_timeout_ms, Some(1500));
}
#[cfg(feature = "outbound-http")]
#[test]
fn outbound_validates_and_lowers_to_policy() {
let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
let entry = &m.filters[0];
entry.validate().expect("valid outbound section");
let opts = entry.load_options();
let policy = opts
.outbound_http
.expect("outbound_http lowered into LoadOptions");
assert_eq!(policy.allow.len(), 2);
assert!(policy.allows(plecto_host::Scheme::Https, "authz.example.com", 8443));
assert!(policy.allows(plecto_host::Scheme::Https, "jwks.example.com", 443));
assert!(!policy.allows(plecto_host::Scheme::Http, "authz.example.com", 8443));
assert_eq!(
policy.classify("10.1.2.3".parse().unwrap()),
plecto_host::AddrVerdict::Allowed
);
assert_eq!(
policy.classify("169.254.169.254".parse().unwrap()),
plecto_host::AddrVerdict::BlockedReserved
);
assert_eq!(
policy.connect_timeout,
std::time::Duration::from_millis(1500)
);
}
#[cfg(feature = "outbound-http")]
#[test]
fn outbound_clamps_oversized_values() {
let toml = r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
[filter.outbound_http]
allow = [{ host = "a.example.com" }]
total_timeout_ms = 999999999
max_concurrent = 100000
"#;
let m = Manifest::from_toml(toml).unwrap();
let policy = m.filters[0].load_options().outbound_http.unwrap();
assert!(policy.total_timeout <= std::time::Duration::from_secs(30));
assert!(policy.max_concurrent <= 64);
}
#[cfg(feature = "outbound-http")]
#[test]
fn outbound_rejects_bad_config() {
let cases = [
(
"allow = [{ host = \"a\" }]\nallow_private = [\"not-a-cidr\"]",
"bad CIDR",
),
(
"allow = [{ host = \"a\" }]\nconnect_timeout_ms = 0",
"zero connect timeout",
),
];
for (body, why) in cases {
let toml = format!(
"[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_http]\n{body}\n"
);
let m = Manifest::from_toml(&toml).unwrap();
assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
}
}
#[cfg(feature = "outbound-http")]
#[test]
fn outbound_allows_empty_allowlist_as_deny_all() {
let toml = r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
[filter.outbound_http]
allow = []
"#;
let m = Manifest::from_toml(toml).unwrap();
m.filters[0]
.validate()
.expect("empty allow is deny-all, not invalid");
let opts = m.filters[0].load_options();
assert!(opts.outbound_http.is_some());
assert!(opts.outbound_http.unwrap().allow.is_empty());
}
const OUTBOUND_TCP_TOML: &str = r#"
[[filter]]
id = "ratelimit-redis"
source = "oci/ratelimit-redis"
digest = "sha256:abc"
[filter.outbound_tcp]
allow = [{ host = "redis.internal", port = 6379 }]
allow_private = ["10.1.0.0/16"]
max_connections = 2
io_deadline_ms = 1500
"#;
#[test]
fn outbound_tcp_section_parses() {
let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
let ob = m.filters[0]
.outbound_tcp
.as_ref()
.expect("outbound_tcp present");
assert_eq!(ob.allow.len(), 1);
assert_eq!(ob.allow[0].host, "redis.internal");
assert_eq!(ob.allow[0].port, 6379);
assert_eq!(ob.allow_private, vec!["10.1.0.0/16".to_string()]);
assert_eq!(ob.max_connections, Some(2));
assert_eq!(ob.io_deadline_ms, Some(1500));
}
#[test]
fn outbound_tcp_port_is_required() {
let toml = r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
[filter.outbound_tcp]
allow = [{ host = "redis.internal" }]
"#;
assert!(Manifest::from_toml(toml).is_err(), "port is mandatory");
}
#[cfg(feature = "outbound-tcp")]
#[test]
fn outbound_tcp_validates_and_lowers_to_policy() {
let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
let entry = &m.filters[0];
entry.validate().expect("valid outbound_tcp section");
let opts = entry.load_options();
let policy = opts
.outbound_tcp
.expect("outbound_tcp lowered into LoadOptions");
assert_eq!(policy.allow.len(), 1);
assert!(policy.allows_name("REDIS.internal")); assert!(!policy.allows_name("evil.internal"));
assert_eq!(
policy.classify("10.1.2.3".parse().unwrap()),
plecto_host::AddrVerdict::Allowed
);
assert_eq!(
policy.classify("169.254.169.254".parse().unwrap()),
plecto_host::AddrVerdict::BlockedReserved
);
assert_eq!(policy.max_connections, 2);
assert_eq!(policy.io_deadline, std::time::Duration::from_millis(1500));
}
#[cfg(feature = "outbound-tcp")]
#[test]
fn outbound_tcp_clamps_oversized_values() {
let toml = r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
[filter.outbound_tcp]
allow = [{ host = "redis.internal", port = 6379 }]
max_connections = 100000
io_deadline_ms = 999999999
"#;
let m = Manifest::from_toml(toml).unwrap();
let policy = m.filters[0].load_options().outbound_tcp.unwrap();
assert!(policy.max_connections <= 64);
assert!(policy.io_deadline <= std::time::Duration::from_secs(30));
}
#[cfg(feature = "outbound-tcp")]
#[test]
fn outbound_tcp_rejects_bad_config() {
let cases = [
("allow = []", "empty allowlist"),
("allow = [{ host = \"a\", port = 0 }]", "port 0"),
(
"allow = [{ host = \"a\", port = 6379 }]\nallow_private = [\"not-a-cidr\"]",
"bad CIDR",
),
(
"allow = [{ host = \"a\", port = 6379 }]\nmax_connections = 0",
"zero connect budget",
),
(
"allow = [{ host = \"a\", port = 6379 }]\nio_deadline_ms = 0",
"zero io deadline",
),
];
for (body, why) in cases {
let toml = format!(
"[[filter]]\nid = \"x\"\nsource = \"s\"\ndigest = \"sha256:abc\"\n[filter.outbound_tcp]\n{body}\n"
);
let m = Manifest::from_toml(&toml).unwrap();
assert!(m.filters[0].validate().is_err(), "{why} must be rejected");
}
}
#[cfg(not(feature = "outbound-tcp"))]
#[test]
fn outbound_tcp_rejected_without_feature() {
let m = Manifest::from_toml(OUTBOUND_TCP_TOML).unwrap();
assert!(
m.filters[0].validate().is_err(),
"outbound_tcp requires the outbound-tcp build"
);
}
#[cfg(not(feature = "outbound-http"))]
#[test]
fn outbound_rejected_without_feature() {
let m = Manifest::from_toml(OUTBOUND_TOML).unwrap();
assert!(
m.filters[0].validate().is_err(),
"outbound_http requires the outbound-http build"
);
}
const WASI_MINIMAL_TOML: &str = r#"
[[filter]]
id = "hello-go"
source = "oci/hello-go"
digest = "sha256:abc"
wasi = "minimal"
"#;
#[test]
fn wasi_defaults_to_none() {
let m = Manifest::from_toml(
r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
"#,
)
.unwrap();
assert_eq!(m.filters[0].wasi, WasiKind::None);
}
#[test]
fn wasi_minimal_parses() {
let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
assert_eq!(m.filters[0].wasi, WasiKind::Minimal);
}
#[cfg(feature = "fat-guest")]
#[test]
fn wasi_minimal_validates_and_lowers_to_load_options() {
let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
let entry = &m.filters[0];
entry.validate().expect("valid wasi = \"minimal\" section");
assert!(entry.load_options().wasi_minimal);
}
#[cfg(not(feature = "fat-guest"))]
#[test]
fn wasi_minimal_rejected_without_feature() {
let m = Manifest::from_toml(WASI_MINIMAL_TOML).unwrap();
assert!(
m.filters[0].validate().is_err(),
"wasi = \"minimal\" requires the fat-guest build"
);
}
#[test]
fn config_section_parses_as_string_map() {
let m = Manifest::from_toml(
r#"
[[filter]]
id = "ratelimit-redis"
source = "oci/ratelimit-redis"
digest = "sha256:abc"
[filter.config]
on_backend_error = "deny"
redis_host = "redis.internal"
redis_port = "6379"
window_seconds = "60"
limit = "1000"
route_tag = "api-v1"
"#,
)
.unwrap();
let cfg = m.filters[0].config.as_ref().expect("config present");
assert_eq!(
cfg.get("on_backend_error").map(String::as_str),
Some("deny")
);
assert_eq!(
cfg.get("redis_host").map(String::as_str),
Some("redis.internal")
);
assert_eq!(cfg.len(), 6);
}
#[test]
fn config_section_absent_by_default() {
let m = Manifest::from_toml(
r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
"#,
)
.unwrap();
assert_eq!(m.filters[0].config, None);
}
}