use crate::models::AppConfig;
use crate::proxy::config::{
ComplianceConfig, CorsConfig, CorsMode, ExperimentalConfig, ProxyConfig, ProxyPoolConfig,
UpstreamProxyConfig, ZaiConfig,
};
use std::fmt;
#[derive(Debug, Clone)]
pub struct ConfigError {
pub field: String,
pub message: String,
pub actual_value: Option<String>,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.actual_value {
Some(val) => write!(f, " • {}: {} (got: {})", self.field, self.message, val),
None => write!(f, " • {}: {}", self.field, self.message),
}
}
}
impl ConfigError {
fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
actual_value: None,
}
}
fn with_value(
field: impl Into<String>,
message: impl Into<String>,
value: impl ToString,
) -> Self {
Self {
field: field.into(),
message: message.into(),
actual_value: Some(value.to_string()),
}
}
}
pub fn validate_app_config(config: &AppConfig) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
validate_proxy_config(&config.proxy, &mut errors);
if config.quota_protection.enabled {
let threshold = config.quota_protection.threshold_percentage;
if threshold == 0 || threshold >= 100 {
errors.push(ConfigError::with_value(
"quota_protection.threshold_percentage",
"must be between 1 and 99",
threshold,
));
}
}
if config.circuit_breaker.enabled {
if config.circuit_breaker.backoff_steps.is_empty() {
errors.push(ConfigError::new(
"circuit_breaker.backoff_steps",
"must not be empty when circuit breaker is enabled",
));
}
for (i, step) in config.circuit_breaker.backoff_steps.iter().enumerate() {
if *step == 0 {
errors.push(ConfigError::with_value(
format!("circuit_breaker.backoff_steps[{}]", i),
"backoff step must be greater than 0",
step,
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_proxy_config(config: &ProxyConfig, errors: &mut Vec<ConfigError>) {
if config.port == 0 {
errors.push(ConfigError::with_value(
"proxy.port",
"must be between 1 and 65535",
config.port,
));
}
if config.api_key.trim().is_empty() {
errors.push(ConfigError::new("proxy.api_key", "must not be empty"));
}
if config.request_timeout == 0 {
errors.push(ConfigError::with_value(
"proxy.request_timeout",
"must be greater than 0",
config.request_timeout,
));
} else if config.request_timeout > 600 {
errors.push(ConfigError::with_value(
"proxy.request_timeout",
"should not exceed 600 seconds (10 minutes)",
config.request_timeout,
));
}
validate_trusted_proxies(config, errors);
validate_upstream_proxy(&config.upstream_proxy, errors);
validate_zai_config(&config.zai, errors);
validate_experimental_config(&config.experimental, errors);
validate_cors_config(&config.cors, errors);
validate_proxy_pool(&config.proxy_pool, errors);
validate_compliance_config(&config.compliance, errors);
validate_google_config(&config.google, errors);
}
fn validate_trusted_proxies(config: &ProxyConfig, errors: &mut Vec<ConfigError>) {
for (index, raw_pattern) in config.trusted_proxies.iter().enumerate() {
let pattern = raw_pattern.trim();
if pattern.is_empty() {
errors.push(ConfigError::new(
format!("proxy.trusted_proxies[{}]", index),
"must not be empty",
));
continue;
}
if let Ok(_ip) = pattern.parse::<std::net::IpAddr>() {
continue;
}
let Some((network, prefix_str)) = pattern.split_once('/') else {
errors.push(ConfigError::with_value(
format!("proxy.trusted_proxies[{}]", index),
"must be a valid IP or CIDR (e.g. 127.0.0.1 or 10.0.0.0/8)",
pattern,
));
continue;
};
let Ok(network_ip) = network.trim().parse::<std::net::IpAddr>() else {
errors.push(ConfigError::with_value(
format!("proxy.trusted_proxies[{}]", index),
"CIDR network must be a valid IP address",
pattern,
));
continue;
};
let Ok(prefix_len) = prefix_str.trim().parse::<u8>() else {
errors.push(ConfigError::with_value(
format!("proxy.trusted_proxies[{}]", index),
"CIDR prefix must be numeric",
pattern,
));
continue;
};
let max_prefix = if network_ip.is_ipv4() { 32 } else { 128 };
if prefix_len > max_prefix {
errors.push(ConfigError::with_value(
format!("proxy.trusted_proxies[{}]", index),
format!(
"CIDR prefix out of range for address family (max {})",
max_prefix
),
pattern,
));
}
}
}
fn validate_cors_config(config: &CorsConfig, errors: &mut Vec<ConfigError>) {
if matches!(config.mode, CorsMode::Permissive) {
return;
}
for (index, raw_origin) in config.allowed_origins.iter().enumerate() {
let origin = raw_origin.trim();
if origin.is_empty() {
errors.push(ConfigError::new(
format!("proxy.cors.allowed_origins[{}]", index),
"must not be empty",
));
continue;
}
let parsed = match url::Url::parse(origin) {
Ok(url) => url,
Err(_) => {
errors.push(ConfigError::with_value(
format!("proxy.cors.allowed_origins[{}]", index),
"must be a valid absolute HTTP(S) origin",
origin,
));
continue;
}
};
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
errors.push(ConfigError::with_value(
format!("proxy.cors.allowed_origins[{}]", index),
"must use http:// or https://",
origin,
));
}
if parsed.host_str().is_none() {
errors.push(ConfigError::with_value(
format!("proxy.cors.allowed_origins[{}]", index),
"must include a host",
origin,
));
}
if parsed.path() != "/"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
errors.push(ConfigError::with_value(
format!("proxy.cors.allowed_origins[{}]", index),
"must be origin-only (no path/query/fragment/userinfo)",
origin,
));
}
}
}
fn validate_upstream_proxy(config: &UpstreamProxyConfig, errors: &mut Vec<ConfigError>) {
if config.enabled && !config.url.is_empty() && !is_valid_proxy_url(&config.url) {
errors.push(ConfigError::with_value(
"proxy.upstream_proxy.url",
"must be a valid proxy URL (http://, https://, or socks5://)",
&config.url,
));
}
}
fn validate_zai_config(config: &ZaiConfig, errors: &mut Vec<ConfigError>) {
if config.enabled {
if config.api_key.trim().is_empty() {
errors.push(ConfigError::new(
"proxy.zai.api_key",
"must not be empty when z.ai is enabled",
));
}
if !config.base_url.starts_with("https://") && !config.base_url.starts_with("http://") {
errors.push(ConfigError::with_value(
"proxy.zai.base_url",
"must be a valid HTTP(S) URL",
&config.base_url,
));
}
}
}
fn validate_experimental_config(config: &ExperimentalConfig, errors: &mut Vec<ConfigError>) {
let l1 = config.context_compression_threshold_l1;
let l2 = config.context_compression_threshold_l2;
let l3 = config.context_compression_threshold_l3;
if !(0.0..=1.0).contains(&l1) {
errors.push(ConfigError::with_value(
"experimental.context_compression_threshold_l1",
"must be between 0.0 and 1.0",
l1,
));
}
if !(0.0..=1.0).contains(&l2) {
errors.push(ConfigError::with_value(
"experimental.context_compression_threshold_l2",
"must be between 0.0 and 1.0",
l2,
));
}
if !(0.0..=1.0).contains(&l3) {
errors.push(ConfigError::with_value(
"experimental.context_compression_threshold_l3",
"must be between 0.0 and 1.0",
l3,
));
}
if l1 >= l2 {
errors.push(ConfigError::with_value(
"experimental.context_compression_threshold_l1",
"L1 threshold must be less than L2",
format!("L1={} >= L2={}", l1, l2),
));
}
if l2 >= l3 {
errors.push(ConfigError::with_value(
"experimental.context_compression_threshold_l2",
"L2 threshold must be less than L3",
format!("L2={} >= L3={}", l2, l3),
));
}
}
fn validate_proxy_pool(config: &ProxyPoolConfig, errors: &mut Vec<ConfigError>) {
if config.enabled {
for (i, proxy) in config.proxies.iter().enumerate() {
if proxy.enabled && !is_valid_proxy_url(&proxy.url) {
errors.push(ConfigError::with_value(
format!("proxy.proxy_pool.proxies[{}].url", i),
"must be a valid proxy URL (http://, https://, or socks5://)",
&proxy.url,
));
}
}
}
}
fn validate_compliance_config(config: &ComplianceConfig, errors: &mut Vec<ConfigError>) {
if !config.enabled {
return;
}
if config.max_global_requests_per_minute == 0 {
errors.push(ConfigError::new(
"proxy.compliance.max_global_requests_per_minute",
"must be greater than 0 when compliance is enabled",
));
}
if config.max_account_requests_per_minute == 0 {
errors.push(ConfigError::new(
"proxy.compliance.max_account_requests_per_minute",
"must be greater than 0 when compliance is enabled",
));
}
if config.max_account_concurrency == 0 {
errors.push(ConfigError::new(
"proxy.compliance.max_account_concurrency",
"must be greater than 0 when compliance is enabled",
));
}
if config.max_retry_attempts == 0 {
errors.push(ConfigError::new(
"proxy.compliance.max_retry_attempts",
"must be greater than 0 when compliance is enabled",
));
}
}
fn validate_google_config(
config: &crate::proxy::config::GoogleConfig,
errors: &mut Vec<ConfigError>,
) {
if config.identity_metadata.ide_type.trim().is_empty() {
errors.push(ConfigError::new(
"proxy.google.identity_metadata.ide_type",
"must not be empty",
));
}
if config.identity_metadata.platform.trim().is_empty() {
errors.push(ConfigError::new(
"proxy.google.identity_metadata.platform",
"must not be empty",
));
}
if config.identity_metadata.plugin_type.trim().is_empty() {
errors.push(ConfigError::new(
"proxy.google.identity_metadata.plugin_type",
"must not be empty",
));
}
let send_x_goog_api_client = config.headers.send_x_goog_api_client.unwrap_or(matches!(
config.mode,
crate::proxy::config::GoogleMode::CodeassistCompat
));
if send_x_goog_api_client && config.headers.x_goog_api_client.trim().is_empty() {
errors.push(ConfigError::new(
"proxy.google.headers.x_goog_api_client",
"must not be empty when x-goog-api-client forwarding is enabled",
));
}
if config.mimic.cooldown_seconds == 0 {
errors.push(ConfigError::new(
"proxy.google.mimic.cooldown_seconds",
"must be greater than 0",
));
}
}
fn is_valid_proxy_url(url: &str) -> bool {
let url_lower = url.to_lowercase();
(url_lower.starts_with("http://")
|| url_lower.starts_with("https://")
|| url_lower.starts_with("socks5://"))
&& url::Url::parse(url).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::AppConfig;
#[test]
fn test_valid_default_config() {
let config = AppConfig::new();
let result = validate_app_config(&config);
assert!(
result.is_ok(),
"Default config should be valid: {:?}",
result
);
}
#[test]
fn test_invalid_port_zero() {
let mut config = AppConfig::new();
config.proxy.port = 0;
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("port")));
}
#[test]
fn test_empty_api_key() {
let mut config = AppConfig::new();
config.proxy.api_key = "".to_string();
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("api_key")));
}
#[test]
fn test_invalid_timeout() {
let mut config = AppConfig::new();
config.proxy.request_timeout = 0;
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("request_timeout")));
}
#[test]
fn test_timeout_too_high() {
let mut config = AppConfig::new();
config.proxy.request_timeout = 1000;
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("request_timeout")));
}
#[test]
fn test_compression_thresholds_ordering() {
let mut config = AppConfig::new();
config.proxy.experimental.context_compression_threshold_l1 = 0.6;
config.proxy.experimental.context_compression_threshold_l2 = 0.5;
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("threshold_l1")));
}
#[test]
fn test_zai_enabled_missing_key() {
let mut config = AppConfig::new();
config.proxy.zai.enabled = true;
config.proxy.zai.api_key = "".to_string();
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("zai.api_key")));
}
#[test]
fn test_upstream_proxy_invalid_url() {
let mut config = AppConfig::new();
config.proxy.upstream_proxy.enabled = true;
config.proxy.upstream_proxy.url = "not-a-valid-url".to_string();
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.field.contains("upstream_proxy.url")));
}
#[test]
fn test_invalid_cors_origin_with_path() {
let mut config = AppConfig::new();
config.proxy.cors.mode = crate::proxy::config::CorsMode::Strict;
config.proxy.cors.allowed_origins = vec!["http://localhost:3000/path".to_string()];
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.field.contains("cors.allowed_origins")));
}
#[test]
fn test_permissive_cors_skips_origin_validation() {
let mut config = AppConfig::new();
config.proxy.cors.mode = crate::proxy::config::CorsMode::Permissive;
config.proxy.cors.allowed_origins = vec!["not-a-url".to_string()];
let result = validate_app_config(&config);
assert!(result.is_ok());
}
#[test]
fn test_invalid_trusted_proxy_entry() {
let mut config = AppConfig::new();
config.proxy.trusted_proxies = vec!["bad-value".to_string()];
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("trusted_proxies")));
}
#[test]
fn test_valid_trusted_proxy_entries() {
let mut config = AppConfig::new();
config.proxy.trusted_proxies = vec![
"127.0.0.1".to_string(),
"10.0.0.0/8".to_string(),
"2001:db8::/32".to_string(),
];
let result = validate_app_config(&config);
assert!(result.is_ok());
}
#[test]
fn test_quota_protection_threshold_bounds() {
let mut config = AppConfig::new();
config.quota_protection.enabled = true;
config.quota_protection.threshold_percentage = 0;
let result = validate_app_config(&config);
assert!(result.is_err());
config.quota_protection.threshold_percentage = 100;
let result = validate_app_config(&config);
assert!(result.is_err());
config.quota_protection.threshold_percentage = 50;
let result = validate_app_config(&config);
assert!(result.is_ok());
}
#[test]
fn test_circuit_breaker_empty_steps() {
let mut config = AppConfig::new();
config.circuit_breaker.enabled = true;
config.circuit_breaker.backoff_steps = vec![];
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field.contains("backoff_steps")));
}
#[test]
fn test_google_identity_metadata_fields_must_not_be_empty() {
let mut config = AppConfig::new();
config.proxy.google.identity_metadata.ide_type = " ".to_string();
config.proxy.google.identity_metadata.platform = "".to_string();
config.proxy.google.identity_metadata.plugin_type = " ".to_string();
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.field.contains("google.identity_metadata.ide_type")));
assert!(errors
.iter()
.any(|e| e.field.contains("google.identity_metadata.platform")));
assert!(errors
.iter()
.any(|e| e.field.contains("google.identity_metadata.plugin_type")));
}
#[test]
fn test_google_x_goog_api_client_value_must_not_be_empty_when_effective() {
let mut config = AppConfig::new();
config.proxy.google.mode = crate::proxy::config::GoogleMode::CodeassistCompat;
config.proxy.google.headers.x_goog_api_client = " ".to_string();
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.field.contains("google.headers.x_goog_api_client")));
}
#[test]
fn test_google_mimic_cooldown_must_be_positive() {
let mut config = AppConfig::new();
config.proxy.google.mimic.cooldown_seconds = 0;
let result = validate_app_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.field.contains("google.mimic.cooldown_seconds")));
}
}