use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::ControlError;
#[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 pool_size: Option<usize>,
pub checkout_timeout_ms: Option<u64>,
pub max_requests_per_instance: 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>>,
#[serde(default)]
pub config_files: Option<BTreeMap<String, String>>,
}
const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
#[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,
}
impl FilterEntry {
pub fn resolved_config(
&self,
base_dir: &Path,
) -> Result<Option<BTreeMap<String, String>>, ControlError> {
let Some(files) = &self.config_files else {
return Ok(None);
};
let mut merged = self.config.clone().unwrap_or_default();
for (key, path) in files {
if merged.contains_key(key) {
return Err(ControlError::InvalidFilterConfig {
id: self.id.clone(),
reason: format!(
"config key {key} is set in both [filter.config] and \
[filter.config_files] — they are mutually exclusive"
),
});
}
let full = base_dir.join(path);
let file = std::fs::File::open(&full).map_err(|e| ControlError::IoAt {
path: full.clone(),
source: e,
})?;
let mut bytes = Vec::new();
file.take(MAX_CONFIG_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| ControlError::IoAt {
path: full.clone(),
source: e,
})?;
if bytes.len() as u64 > MAX_CONFIG_FILE_BYTES {
return Err(ControlError::InvalidFilterConfig {
id: self.id.clone(),
reason: format!(
"config file for key {key} ({}) exceeds the {MAX_CONFIG_FILE_BYTES}-byte \
cap",
full.display()
),
});
}
let text = String::from_utf8(bytes).map_err(|_| ControlError::InvalidFilterConfig {
id: self.id.clone(),
reason: format!(
"config file for key {key} ({}) is not valid UTF-8 — host-config serves \
strings",
full.display()
),
})?;
merged.insert(key.clone(), text.trim().to_string());
}
Ok(Some(merged))
}
}
#[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_files_resolve_into_the_config_map_trimmed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hmac_key"), "s3cret-value\n").unwrap();
let m = Manifest::from_toml(
r#"
[[filter]]
id = "session-auth"
source = "oci/session-auth"
digest = "sha256:abc"
[filter.config]
cookie_name = "session"
[filter.config_files]
hmac_key = "hmac_key"
"#,
)
.unwrap();
let cfg = m.filters[0]
.resolved_config(dir.path())
.unwrap()
.expect("config_files present resolves to a merged map");
assert_eq!(
cfg.get("hmac_key").map(String::as_str),
Some("s3cret-value")
);
assert_eq!(cfg.get("cookie_name").map(String::as_str), Some("session"));
}
#[test]
fn config_files_reject_a_key_collision_with_config() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hmac_key"), "x").unwrap();
let m = Manifest::from_toml(
r#"
[[filter]]
id = "session-auth"
source = "oci/session-auth"
digest = "sha256:abc"
[filter.config]
hmac_key = "inline"
[filter.config_files]
hmac_key = "hmac_key"
"#,
)
.unwrap();
let err = m.filters[0].resolved_config(dir.path()).unwrap_err();
assert!(
err.to_string().contains("hmac_key"),
"the collision names the key, got: {err}"
);
}
#[test]
fn config_files_fail_closed_on_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let m = Manifest::from_toml(
r#"
[[filter]]
id = "session-auth"
source = "oci/session-auth"
digest = "sha256:abc"
[filter.config_files]
hmac_key = "no-such-file"
"#,
)
.unwrap();
assert!(m.filters[0].resolved_config(dir.path()).is_err());
}
#[test]
fn config_files_fail_closed_on_non_utf8_and_oversize() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("binary"), [0xFFu8, 0xFE, 0x00]).unwrap();
std::fs::write(dir.path().join("huge"), vec![b'a'; 1024 * 1024 + 1]).unwrap();
for file in ["binary", "huge"] {
let m = Manifest::from_toml(&format!(
r#"
[[filter]]
id = "session-auth"
source = "oci/session-auth"
digest = "sha256:abc"
[filter.config_files]
hmac_key = "{file}"
"#
))
.unwrap();
assert!(
m.filters[0].resolved_config(dir.path()).is_err(),
"{file} must be rejected"
);
}
}
#[test]
fn resolved_config_is_none_without_config_files() {
let m = Manifest::from_toml(
r#"
[[filter]]
id = "x"
source = "s"
digest = "sha256:abc"
[filter.config]
k = "v"
"#,
)
.unwrap();
assert!(
m.filters[0]
.resolved_config(Path::new("."))
.unwrap()
.is_none()
);
}
#[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);
}
}