use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkConfig {
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub socks_proxy: Option<String>,
pub no_proxy: Option<NoProxy>,
pub auth: Option<ProxyAuth>,
pub tls: Option<TlsConfig>,
#[serde(default)]
pub overrides: HashMap<String, NetworkOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NoProxy {
String(String),
Array(Vec<String>),
}
impl NoProxy {
#[allow(dead_code)] pub fn to_hosts(&self) -> Vec<String> {
match self {
NoProxy::String(s) => s.split(',').map(|h| h.trim().to_string()).collect(),
NoProxy::Array(arr) => arr.clone(),
}
}
#[allow(dead_code)] pub fn to_env_string(&self) -> String {
self.to_hosts().join(",")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyAuth {
pub username: String,
pub password: PasswordSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PasswordSource {
Plain(String),
#[serde(rename_all = "snake_case")]
Env(String),
#[serde(rename_all = "snake_case")]
File(String),
Prompt,
}
impl PasswordSource {
#[allow(dead_code)] pub fn resolve(&self) -> Result<String, String> {
match self {
PasswordSource::Plain(p) => {
eprintln!(
"Warning: Using plain text proxy password. Consider using env or file source."
);
Ok(p.clone())
}
PasswordSource::Env(var) => {
std::env::var(var).map_err(|_| format!("Environment variable {} not set", var))
}
PasswordSource::File(path) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
let safe_path = crate::network::redact_home(path);
tracing::warn!(
event = "proxy.password_permissive_perms",
path = %safe_path,
mode = format!("{:o}", mode & 0o777),
"proxy password file has permissive permissions; chmod 600 recommended"
);
}
}
}
std::fs::read_to_string(path)
.map(|s| s.trim().to_string())
.map_err(|e| format!("Failed to read password file {}: {}", path, e))
}
PasswordSource::Prompt => {
Err("Interactive password prompt not available in this context".to_string())
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
pub ca_bundle: Option<String>,
#[serde(default)]
pub insecure: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkOverride {
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub socks_proxy: Option<String>,
pub no_proxy: Option<NoProxy>,
#[serde(default)]
pub no_proxy_all: bool,
}
#[allow(dead_code)] impl NetworkConfig {
pub fn has_proxy(&self) -> bool {
self.http_proxy.is_some() || self.https_proxy.is_some() || self.socks_proxy.is_some()
}
pub fn effective_http_proxy(&self) -> Option<&str> {
self.http_proxy.as_deref().or(self.https_proxy.as_deref())
}
pub fn effective_https_proxy(&self) -> Option<&str> {
self.https_proxy.as_deref().or(self.http_proxy.as_deref())
}
pub fn should_bypass(&self, host: &str) -> bool {
if let Some(no_proxy) = &self.no_proxy {
let hosts = no_proxy.to_hosts();
for pattern in hosts {
if pattern.starts_with('.') {
if host.ends_with(&pattern) || host == &pattern[1..] {
return true;
}
} else if host == pattern {
return true;
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_network_config_has_proxy() {
let mut config = NetworkConfig::default();
assert!(!config.has_proxy());
config.https_proxy = Some("http://proxy:8080".to_string());
assert!(config.has_proxy());
}
#[test]
fn test_should_bypass_exact_match() {
let config = NetworkConfig {
no_proxy: Some(NoProxy::String("localhost,127.0.0.1".to_string())),
..Default::default()
};
assert!(config.should_bypass("localhost"));
assert!(config.should_bypass("127.0.0.1"));
assert!(!config.should_bypass("example.com"));
}
#[test]
fn test_should_bypass_suffix_match() {
let config = NetworkConfig {
no_proxy: Some(NoProxy::String(".corp.com".to_string())),
..Default::default()
};
assert!(config.should_bypass("foo.corp.com"));
assert!(config.should_bypass("bar.foo.corp.com"));
assert!(config.should_bypass("corp.com"));
assert!(!config.should_bypass("example.com"));
}
#[test]
fn test_effective_proxy_fallback() {
let config = NetworkConfig {
https_proxy: Some("https://proxy:8080".to_string()),
..Default::default()
};
assert_eq!(config.effective_http_proxy(), Some("https://proxy:8080"));
assert_eq!(config.effective_https_proxy(), Some("https://proxy:8080"));
}
}