use serde::Deserialize;
use camel_component_api::CamelError;
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct HttpConfig {
#[serde(default = "default_connect_timeout_ms")]
pub connect_timeout_ms: u64,
#[serde(default = "default_pool_max_idle_per_host")]
pub pool_max_idle_per_host: usize,
#[serde(default = "default_pool_idle_timeout_ms")]
pub pool_idle_timeout_ms: u64,
#[serde(default)]
pub follow_redirects: bool,
#[serde(default)]
pub max_redirects: Option<usize>,
#[serde(default = "default_response_timeout_ms")]
pub response_timeout_ms: u64,
#[serde(default = "default_read_timeout_ms")]
pub read_timeout_ms: u64,
#[serde(default = "default_max_body_size")]
pub max_body_size: usize,
#[serde(default = "default_max_response_bytes")]
pub max_response_bytes: usize,
#[serde(default = "default_max_request_body")]
pub max_request_body: usize,
#[serde(default)]
pub allow_internal: bool,
#[serde(default)]
pub blocked_hosts: Vec<String>,
#[serde(default)]
pub ok_status_code_range: Option<String>,
#[serde(default)]
pub tls: Option<TlsConfig>,
#[serde(default)]
pub proxy_url: Option<String>,
}
#[derive(Clone, PartialEq, Deserialize)]
pub struct TlsConfig {
pub enabled: bool,
#[serde(default = "default_verify_peer")]
pub verify_peer: bool,
#[serde(default)]
pub ca_cert_path: Option<String>,
#[serde(default)]
pub client_cert_path: Option<String>,
#[serde(default)]
pub client_key_path: Option<String>,
#[serde(default)]
pub insecure: bool,
}
fn default_verify_peer() -> bool {
true
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
enabled: false,
verify_peer: default_verify_peer(),
ca_cert_path: None,
client_cert_path: None,
client_key_path: None,
insecure: false,
}
}
}
impl std::fmt::Debug for TlsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsConfig")
.field("enabled", &self.enabled)
.field("verify_peer", &self.verify_peer)
.field("ca_cert_path", &"[REDACTED]")
.field("client_cert_path", &"[REDACTED]")
.field("client_key_path", &"[REDACTED]")
.field("insecure", &self.insecure)
.finish()
}
}
#[derive(Clone)]
pub struct ServerTlsConfig {
pub cert_path: String,
pub key_path: String,
}
impl std::fmt::Debug for ServerTlsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerTlsConfig")
.field("cert_path", &"[REDACTED]")
.field("key_path", &"[REDACTED]")
.finish()
}
}
fn default_connect_timeout_ms() -> u64 {
5_000
}
fn default_pool_max_idle_per_host() -> usize {
100
}
fn default_pool_idle_timeout_ms() -> u64 {
90_000
}
fn default_response_timeout_ms() -> u64 {
30_000
}
fn default_read_timeout_ms() -> u64 {
30_000
}
fn default_max_body_size() -> usize {
10_485_760
}
fn default_max_response_bytes() -> usize {
10_485_760
}
fn default_max_request_body() -> usize {
2_097_152
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
connect_timeout_ms: default_connect_timeout_ms(),
pool_max_idle_per_host: default_pool_max_idle_per_host(),
pool_idle_timeout_ms: default_pool_idle_timeout_ms(),
follow_redirects: false,
max_redirects: None,
response_timeout_ms: default_response_timeout_ms(),
read_timeout_ms: default_read_timeout_ms(),
max_body_size: default_max_body_size(),
max_response_bytes: default_max_response_bytes(),
max_request_body: default_max_request_body(),
allow_internal: false,
blocked_hosts: Vec::new(),
ok_status_code_range: None,
tls: None,
proxy_url: None,
}
}
}
impl HttpConfig {
pub fn validate(&self) -> Result<(), CamelError> {
if let Some(max_redirects) = self.max_redirects
&& max_redirects > 20
{
return Err(CamelError::Config(
"max_redirects must be <= 20".to_string(),
));
}
if let Some(range) = &self.ok_status_code_range {
parse_ok_status_code_range(range)?;
}
if self.proxy_url.is_some() {
return Err(CamelError::Config(
"proxy_url is incompatible with SSRF DNS pinning and cannot be used".to_string(),
));
}
Ok(())
}
pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {
self.connect_timeout_ms = ms;
self
}
pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
self.pool_max_idle_per_host = n;
self
}
pub fn with_pool_idle_timeout_ms(mut self, ms: u64) -> Self {
self.pool_idle_timeout_ms = ms;
self
}
pub fn with_follow_redirects(mut self, follow: bool) -> Self {
self.follow_redirects = follow;
self
}
pub fn with_max_redirects(mut self, max_redirects: Option<usize>) -> Self {
self.max_redirects = max_redirects;
self
}
pub fn with_response_timeout_ms(mut self, ms: u64) -> Self {
self.response_timeout_ms = ms;
self
}
pub fn with_read_timeout_ms(mut self, ms: u64) -> Self {
self.read_timeout_ms = ms;
self
}
pub fn with_max_body_size(mut self, n: usize) -> Self {
self.max_body_size = n;
self
}
pub fn with_max_response_bytes(mut self, n: usize) -> Self {
self.max_response_bytes = n;
self
}
pub fn with_max_request_body(mut self, n: usize) -> Self {
self.max_request_body = n;
self
}
pub fn with_allow_internal(mut self, allow: bool) -> Self {
self.allow_internal = allow;
self
}
pub fn with_blocked_hosts(mut self, hosts: Vec<String>) -> Self {
self.blocked_hosts = hosts;
self
}
pub fn with_ok_status_code_range(mut self, range: Option<String>) -> Self {
self.ok_status_code_range = range;
self
}
pub fn with_tls(mut self, tls: Option<TlsConfig>) -> Self {
self.tls = tls;
self
}
}
pub(crate) fn parse_ok_status_code_range(range: &str) -> Result<(u16, u16), CamelError> {
let (start_str, end_str) = range.split_once('-').ok_or_else(|| {
CamelError::Config("ok_status_code_range must be in NNN-NNN format".to_string())
})?;
if start_str.len() != 3
|| end_str.len() != 3
|| !start_str.chars().all(|c| c.is_ascii_digit())
|| !end_str.chars().all(|c| c.is_ascii_digit())
{
return Err(CamelError::Config(
"ok_status_code_range must be in NNN-NNN format".to_string(),
));
}
let start = start_str
.parse::<u16>()
.map_err(|_| CamelError::Config("ok_status_code_range start is invalid".to_string()))?;
let end = end_str
.parse::<u16>()
.map_err(|_| CamelError::Config("ok_status_code_range end is invalid".to_string()))?;
if start > end {
return Err(CamelError::Config(
"ok_status_code_range start must be <= end".to_string(),
));
}
Ok((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_config_defaults() {
let cfg = HttpConfig::default();
assert_eq!(cfg.connect_timeout_ms, 5_000);
assert_eq!(cfg.pool_max_idle_per_host, 100);
assert_eq!(cfg.pool_idle_timeout_ms, 90_000);
assert!(!cfg.follow_redirects);
assert_eq!(cfg.max_redirects, None);
assert_eq!(cfg.response_timeout_ms, 30_000);
assert_eq!(cfg.max_body_size, 10_485_760);
assert_eq!(cfg.max_request_body, 2_097_152);
assert!(!cfg.allow_internal);
assert!(cfg.blocked_hosts.is_empty());
assert!(cfg.tls.is_none());
assert!(cfg.proxy_url.is_none());
}
#[test]
fn test_http_config_builder() {
let cfg = HttpConfig::default()
.with_connect_timeout_ms(1_000)
.with_pool_max_idle_per_host(50)
.with_follow_redirects(true)
.with_allow_internal(true)
.with_blocked_hosts(vec!["evil.com".to_string()]);
assert_eq!(cfg.connect_timeout_ms, 1_000);
assert_eq!(cfg.pool_max_idle_per_host, 50);
assert!(cfg.follow_redirects);
assert!(cfg.allow_internal);
assert_eq!(cfg.blocked_hosts, vec!["evil.com".to_string()]);
assert_eq!(cfg.response_timeout_ms, 30_000);
}
#[test]
fn test_rejects_max_redirects_over_limit() {
let cfg = HttpConfig {
max_redirects: Some(21),
..HttpConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_accepts_valid_max_redirects() {
let cfg = HttpConfig {
max_redirects: Some(10),
..HttpConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn test_rejects_malformed_status_range() {
let cfg = HttpConfig {
ok_status_code_range: Some("abc-xyz".into()),
..HttpConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_accepts_valid_status_range() {
let cfg = HttpConfig {
ok_status_code_range: Some("200-299".into()),
..HttpConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn test_rejects_proxy_url_with_invalid_url() {
let cfg = HttpConfig {
proxy_url: Some("::not-a-proxy::".into()),
..HttpConfig::default()
};
let err = cfg.validate().expect_err("proxy_url must be rejected");
assert!(
err.to_string()
.contains("incompatible with SSRF DNS pinning"),
"expected SSRF rejection message, got: {err}"
);
}
#[test]
fn test_rejects_proxy_url_with_valid_url() {
let cfg = HttpConfig {
proxy_url: Some("http://proxy:8080".into()),
..HttpConfig::default()
};
let err = cfg
.validate()
.expect_err("valid proxy_url must also be rejected");
assert!(
err.to_string()
.contains("incompatible with SSRF DNS pinning"),
"expected SSRF rejection message, got: {err}"
);
}
#[test]
fn test_proxy_url_toml_deserialize_then_reject() {
let toml_src = r#"
connect_timeout_ms = 5000
proxy_url = "http://proxy:8080"
"#;
let cfg: HttpConfig = toml::from_str(toml_src).expect("toml must deserialize");
assert_eq!(cfg.proxy_url.as_deref(), Some("http://proxy:8080"));
let err = cfg
.validate()
.expect_err("validate must reject deserialized proxy_url");
assert!(
err.to_string()
.contains("incompatible with SSRF DNS pinning"),
"expected SSRF rejection message, got: {err}"
);
}
#[test]
fn server_tls_config_debug_redacts_paths() {
let cfg = super::ServerTlsConfig {
cert_path: "/secret/cert.pem".to_string(),
key_path: "/secret/key.pem".to_string(),
};
let debug = format!("{:?}", cfg);
assert!(
!debug.contains("/secret"),
"paths must be redacted: {debug}"
);
assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
}
#[test]
fn tls_config_debug_redacts_paths() {
let cfg = super::TlsConfig {
enabled: true,
verify_peer: true,
ca_cert_path: Some("/secret/ca.pem".to_string()),
client_cert_path: Some("/secret/cert.pem".to_string()),
client_key_path: Some("/secret/key.pem".to_string()),
insecure: false,
};
let debug = format!("{:?}", cfg);
assert!(
!debug.contains("/secret"),
"sensitive paths must be redacted: {debug}"
);
assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
assert!(
debug.contains("enabled: true"),
"non-sensitive fields must stay visible: {debug}"
);
}
}