use crate::profile::CustomCredentialDef;
use nono::{NonoError, Result};
use nono_proxy::config::{EndpointRule, InjectMode, ProxyConfig, RouteConfig};
use serde::Deserialize;
use std::collections::HashMap;
use tracing::debug;
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkPolicy {
#[allow(dead_code)]
pub meta: NetworkPolicyMeta,
pub groups: HashMap<String, NetworkGroup>,
#[serde(default)]
pub profiles: HashMap<String, NetworkProfileDef>,
#[serde(default)]
pub credentials: HashMap<String, CredentialDef>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkPolicyMeta {
#[allow(dead_code)]
pub version: u64,
#[allow(dead_code)]
pub schema_version: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkGroup {
#[allow(dead_code)]
pub description: String,
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default)]
pub suffixes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkProfileDef {
pub groups: Vec<String>,
#[serde(default)]
pub credentials: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CredentialDef {
pub upstream: String,
#[serde(default)]
pub credential_key: Option<String>,
#[serde(default = "default_inject_header")]
pub inject_header: String,
#[serde(default)]
pub credential_format: Option<String>,
#[serde(default)]
pub env_var: Option<String>,
#[serde(default)]
pub endpoint_rules: Vec<EndpointRule>,
}
fn default_inject_header() -> String {
"Authorization".to_string()
}
#[derive(Debug, Clone)]
pub struct ResolvedNetworkPolicy {
pub hosts: Vec<String>,
pub suffixes: Vec<String>,
pub routes: Vec<RouteConfig>,
pub profile_credentials: Vec<String>,
}
pub fn load_network_policy(json: &str) -> Result<NetworkPolicy> {
serde_json::from_str(json)
.map_err(|e| NonoError::ConfigParse(format!("Failed to parse network-policy.json: {}", e)))
}
pub fn resolve_network_profile(
policy: &NetworkPolicy,
profile_name: &str,
) -> Result<ResolvedNetworkPolicy> {
let profile = policy.profiles.get(profile_name).ok_or_else(|| {
NonoError::ConfigParse(format!(
"Network profile '{}' not found in policy",
profile_name
))
})?;
let mut resolved = resolve_groups(policy, &profile.groups)?;
resolved.profile_credentials = profile.credentials.clone();
Ok(resolved)
}
pub fn resolve_groups(
policy: &NetworkPolicy,
group_names: &[String],
) -> Result<ResolvedNetworkPolicy> {
let mut hosts = Vec::new();
let mut suffixes = Vec::new();
for name in group_names {
let group = policy.groups.get(name).ok_or_else(|| {
NonoError::ConfigParse(format!("Network group '{}' not found in policy", name))
})?;
debug!(
"Resolving network group: {} ({} hosts, {} suffixes)",
name,
group.hosts.len(),
group.suffixes.len()
);
hosts.extend(group.hosts.clone());
suffixes.extend(group.suffixes.clone());
}
hosts.sort();
hosts.dedup();
suffixes.sort();
suffixes.dedup();
Ok(ResolvedNetworkPolicy {
hosts,
suffixes,
routes: Vec::new(),
profile_credentials: Vec::new(),
})
}
pub fn resolve_credentials(
policy: &NetworkPolicy,
service_names: &[String],
custom_credentials: &HashMap<String, CustomCredentialDef>,
) -> Result<Vec<RouteConfig>> {
let mut all_names: Vec<String> = custom_credentials.keys().cloned().collect();
for name in service_names {
if !all_names.contains(name) {
all_names.push(name.clone());
}
}
if all_names.is_empty() {
return Ok(Vec::new());
}
for name in service_names {
if !custom_credentials.contains_key(name) && !policy.credentials.contains_key(name) {
let mut available: Vec<_> = policy.credentials.keys().cloned().collect();
available.extend(custom_credentials.keys().cloned());
available.sort();
available.dedup();
return Err(NonoError::ConfigParse(format!(
"Unknown credential service '{}'. Available: {:?}",
name, available
)));
}
}
let mut routes = Vec::new();
for name in &all_names {
if let Some(cred) = custom_credentials.get(name) {
if let Some(ref env_var) = cred.env_var {
nono::validate_destination_env_var(env_var).map_err(|e| {
NonoError::ConfigParse(format!(
"custom credential '{}' has invalid env_var: {}",
name, e
))
})?;
}
let oauth2 = cred.auth.clone();
routes.push(RouteConfig {
prefix: name.clone(),
upstream: cred.upstream.clone(),
credential_key: cred.credential_key.clone(),
inject_mode: cred.inject_mode.clone(),
inject_header: cred.inject_header.clone(),
credential_format: cred.credential_format.clone(),
path_pattern: cred.path_pattern.clone(),
path_replacement: cred.path_replacement.clone(),
query_param_name: cred.query_param_name.clone(),
proxy: cred.proxy.clone(),
env_var: cred.env_var.clone(),
endpoint_rules: cred.endpoint_rules.clone(),
tls_ca: cred
.tls_ca
.as_deref()
.map(|p| {
crate::policy::expand_path(p).map(|pb| pb.to_string_lossy().into_owned())
})
.transpose()?,
tls_client_cert: cred
.tls_client_cert
.as_deref()
.map(|p| {
crate::policy::expand_path(p).map(|pb| pb.to_string_lossy().into_owned())
})
.transpose()?,
tls_client_key: cred
.tls_client_key
.as_deref()
.map(|p| {
crate::policy::expand_path(p).map(|pb| pb.to_string_lossy().into_owned())
})
.transpose()?,
oauth2,
aws_auth: cred.aws_auth.clone(),
});
} else if let Some(cred) = policy.credentials.get(name) {
if let Some(ref env_var) = cred.env_var {
nono::validate_destination_env_var(env_var).map_err(|e| {
NonoError::ConfigParse(format!(
"credential '{}' has invalid env_var: {}",
name, e
))
})?;
}
let key = cred.credential_key.clone().unwrap_or_else(|| name.clone());
routes.push(RouteConfig {
prefix: name.clone(),
upstream: cred.upstream.clone(),
credential_key: Some(key),
inject_mode: InjectMode::Header,
inject_header: cred.inject_header.clone(),
credential_format: cred.credential_format.clone(),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: cred.env_var.clone(),
endpoint_rules: cred.endpoint_rules.clone(),
tls_ca: None, tls_client_cert: None,
tls_client_key: None,
oauth2: None,
aws_auth: None,
});
}
}
Ok(routes)
}
pub fn build_proxy_config(resolved: &ResolvedNetworkPolicy, extra_hosts: &[String]) -> ProxyConfig {
let mut allowed_hosts = resolved.hosts.clone();
for suffix in &resolved.suffixes {
let wildcard = if suffix.starts_with('.') {
format!("*{}", suffix)
} else {
format!("*.{}", suffix)
};
allowed_hosts.push(wildcard);
}
allowed_hosts.extend(extra_hosts.iter().cloned());
ProxyConfig {
allowed_hosts,
routes: resolved.routes.clone(),
..Default::default()
}
}
pub fn expand_proxy_allow(policy: &NetworkPolicy, entries: &[String]) -> Vec<String> {
let mut result = Vec::new();
for entry in entries {
if let Some(group) = policy.groups.get(entry.as_str()) {
result.extend(group.hosts.clone());
for suffix in &group.suffixes {
let wildcard = if suffix.starts_with('.') {
format!("*{}", suffix)
} else {
format!("*.{}", suffix)
};
result.push(wildcard);
}
} else {
let host = entry
.rsplit_once(':')
.and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h))
.unwrap_or(entry.as_str());
result.push(host.to_string());
}
}
result
}
fn is_loopback_domain(domain: &str) -> bool {
domain == "localhost"
|| domain
.parse::<std::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
|| domain
.parse::<std::net::Ipv6Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
pub fn partition_allow_domain(
policy: &NetworkPolicy,
entries: &[crate::profile::AllowDomainEntry],
) -> Result<(Vec<String>, Vec<RouteConfig>)> {
let mut plain_hosts = Vec::new();
let mut endpoint_routes = Vec::new();
for entry in entries {
match entry {
crate::profile::AllowDomainEntry::Plain(host) => {
let expanded = expand_proxy_allow(policy, std::slice::from_ref(host));
plain_hosts.extend(expanded);
}
crate::profile::AllowDomainEntry::WithEndpoints { domain, endpoints } => {
if endpoints.is_empty() {
let expanded = expand_proxy_allow(policy, std::slice::from_ref(domain));
plain_hosts.extend(expanded);
} else {
if domain.is_empty() {
return Err(NonoError::ConfigParse(
"allow_domain entry with endpoints must have a non-empty domain"
.to_string(),
));
}
let prefix = format!("_ep_{}", domain);
let scheme = if is_loopback_domain(domain) {
"http"
} else {
"https"
};
endpoint_routes.push(RouteConfig {
prefix,
upstream: format!("{}://{}", scheme, domain),
credential_key: None,
inject_mode: InjectMode::default(),
inject_header: "Authorization".to_string(),
credential_format: None,
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: endpoints.clone(),
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
oauth2: None,
aws_auth: None,
});
}
}
}
}
Ok((plain_hosts, endpoint_routes))
}
pub fn collect_allow_domain_port_warnings(entries: &[String], source: &str) -> Vec<String> {
entries
.iter()
.filter_map(|entry| {
entry
.rsplit_once(':')
.and_then(|(_host, port)| port.parse::<u16>().ok())
.map(|_| {
format!(
"{source} entry '{entry}' includes a :port suffix. nono now ignores ports in allow-domain rules and only applies hostname filtering through the proxy."
)
})
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::embedded::embedded_network_policy_json;
#[test]
fn test_load_embedded_network_policy() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
assert!(!policy.groups.is_empty());
assert!(!policy.profiles.is_empty());
}
#[test]
fn test_resolve_developer_profile() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let resolved = resolve_network_profile(&policy, "developer").unwrap();
assert!(!resolved.hosts.is_empty());
assert!(resolved.hosts.contains(&"api.openai.com".to_string()));
assert!(resolved.hosts.contains(&"api.anthropic.com".to_string()));
}
#[test]
fn test_resolve_minimal_profile() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let resolved = resolve_network_profile(&policy, "minimal").unwrap();
assert!(resolved.hosts.contains(&"api.openai.com".to_string()));
assert!(!resolved.hosts.contains(&"registry.npmjs.org".to_string()));
}
#[test]
fn test_resolve_nonexistent_profile() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
assert!(resolve_network_profile(&policy, "nonexistent").is_err());
}
#[test]
fn test_resolve_enterprise_has_suffixes() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let resolved = resolve_network_profile(&policy, "enterprise").unwrap();
assert!(!resolved.suffixes.is_empty());
assert!(resolved.suffixes.contains(&".googleapis.com".to_string()));
}
#[test]
fn test_resolve_credentials_empty_returns_none() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let routes = resolve_credentials(&policy, &[], &HashMap::new()).unwrap();
assert!(routes.is_empty());
}
#[test]
fn test_resolve_credentials_by_name() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let routes = resolve_credentials(
&policy,
&["openai".to_string(), "anthropic".to_string()],
&HashMap::new(),
)
.unwrap();
assert!(!routes.is_empty());
let openai_route = routes.iter().find(|r| r.prefix == "openai");
assert!(openai_route.is_some());
assert_eq!(openai_route.unwrap().upstream, "https://api.openai.com/v1");
}
#[test]
fn test_resolve_credentials_filtered() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let routes =
resolve_credentials(&policy, &["openai".to_string()], &HashMap::new()).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].prefix, "openai");
}
#[test]
fn test_resolve_credentials_unknown_service_fails() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let result = resolve_credentials(
&policy,
&["nonexistent_service".to_string()],
&HashMap::new(),
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("nonexistent_service"));
assert!(err.contains("Unknown credential service"));
}
#[test]
fn test_resolve_credentials_with_custom() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"telegram".to_string(),
CustomCredentialDef {
upstream: "https://api.telegram.org".to_string(),
credential_key: Some("telegram_bot_token".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["telegram".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].prefix, "telegram");
assert_eq!(routes[0].upstream, "https://api.telegram.org");
assert_eq!(
routes[0].credential_key,
Some("telegram_bot_token".to_string())
);
}
#[test]
fn test_resolve_credentials_custom_overrides_builtin() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"openai".to_string(),
CustomCredentialDef {
upstream: "https://my-proxy.example.com/openai".to_string(),
credential_key: Some("my_openai_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "X-Custom-Auth".to_string(),
credential_format: Some("Token {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["openai".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].upstream, "https://my-proxy.example.com/openai");
assert_eq!(routes[0].credential_key, Some("my_openai_key".to_string()));
assert_eq!(routes[0].inject_header, "X-Custom-Auth");
}
#[test]
fn test_resolve_credentials_mixed_custom_and_builtin() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"telegram".to_string(),
CustomCredentialDef {
upstream: "https://api.telegram.org".to_string(),
credential_key: Some("telegram_bot_token".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(
&policy,
&["openai".to_string(), "telegram".to_string()],
&custom,
)
.unwrap();
assert_eq!(routes.len(), 2);
let openai = routes.iter().find(|r| r.prefix == "openai").unwrap();
assert_eq!(openai.upstream, "https://api.openai.com/v1");
let telegram = routes.iter().find(|r| r.prefix == "telegram").unwrap();
assert_eq!(telegram.upstream, "https://api.telegram.org"); }
#[test]
fn test_custom_credential_http_localhost_allowed() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"local".to_string(),
CustomCredentialDef {
upstream: "http://localhost:8080/api".to_string(),
credential_key: Some("local_api_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["local".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].upstream, "http://localhost:8080/api");
}
#[test]
fn test_build_proxy_config() {
let resolved = ResolvedNetworkPolicy {
hosts: vec!["api.openai.com".to_string()],
suffixes: vec![".googleapis.com".to_string()],
routes: vec![],
profile_credentials: vec![],
};
let config = build_proxy_config(&resolved, &["extra.example.com".to_string()]);
assert!(config.allowed_hosts.contains(&"api.openai.com".to_string()));
assert!(
config
.allowed_hosts
.contains(&"*.googleapis.com".to_string())
);
assert!(
config
.allowed_hosts
.contains(&"extra.example.com".to_string())
);
}
#[test]
fn test_deduplication() {
let json = r#"{
"meta": { "version": 1, "schema_version": "1.0" },
"groups": {
"a": { "description": "A", "hosts": ["foo.com", "bar.com"] },
"b": { "description": "B", "hosts": ["bar.com", "baz.com"] }
},
"profiles": {},
"credentials": {}
}"#;
let policy = load_network_policy(json).unwrap();
let resolved = resolve_groups(&policy, &["a".to_string(), "b".to_string()]).unwrap();
assert_eq!(resolved.hosts.iter().filter(|h| *h == "bar.com").count(), 1);
assert_eq!(resolved.hosts.len(), 3);
}
#[test]
fn test_custom_credential_http_127_cidr_allowed() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"local".to_string(),
CustomCredentialDef {
upstream: "http://127.1.2.3:8080/api".to_string(),
credential_key: Some("local_api_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["local".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
}
#[test]
fn test_custom_credential_http_0_0_0_0_allowed() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"local".to_string(),
CustomCredentialDef {
upstream: "http://0.0.0.0:3000/api".to_string(),
credential_key: Some("local_api_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["local".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
}
#[test]
fn test_custom_credential_with_valid_header() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"test".to_string(),
CustomCredentialDef {
upstream: "https://api.example.com".to_string(),
credential_key: Some("api_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "X-Custom-Auth".to_string(),
credential_format: Some("Token {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["test".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].inject_header, "X-Custom-Auth");
assert_eq!(routes[0].credential_format.as_deref(), Some("Token {}"));
}
#[test]
fn test_resolve_credentials_propagates_env_var() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let mut custom = HashMap::new();
custom.insert(
"openai".to_string(),
CustomCredentialDef {
upstream: "https://api.openai.com/v1".to_string(),
credential_key: Some("op://Development/OpenAI/credential".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: Some("OPENAI_API_KEY".to_string()),
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes =
resolve_credentials(&policy, &["openai".to_string()], &custom).expect("should resolve");
assert_eq!(routes.len(), 1);
assert_eq!(
routes[0].env_var,
Some("OPENAI_API_KEY".to_string()),
"env_var must be propagated from CustomCredentialDef to RouteConfig"
);
}
#[test]
fn test_resolve_credentials_builtin_without_env_var() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let custom = HashMap::new();
let routes =
resolve_credentials(&policy, &["openai".to_string()], &custom).expect("should resolve");
assert_eq!(routes.len(), 1);
assert_eq!(
routes[0].env_var, None,
"Built-in credentials without env_var field should have None"
);
}
#[test]
fn test_resolve_github_credential() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let custom = HashMap::new();
let routes =
resolve_credentials(&policy, &["github".to_string()], &custom).expect("should resolve");
assert_eq!(routes.len(), 1);
let github = &routes[0];
assert_eq!(github.prefix, "github");
assert_eq!(github.upstream, "https://api.github.com");
assert_eq!(
github.credential_key,
Some("env://GITHUB_TOKEN".to_string())
);
assert_eq!(github.credential_format.as_deref(), Some("token {}"));
assert_eq!(
github.env_var,
Some("GITHUB_TOKEN".to_string()),
"github credential must have explicit env_var for phantom token"
);
}
#[test]
fn test_resolve_gitlab_credential() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let custom = HashMap::new();
let routes =
resolve_credentials(&policy, &["gitlab".to_string()], &custom).expect("should resolve");
assert_eq!(routes.len(), 1);
let gitlab = &routes[0];
assert_eq!(gitlab.prefix, "gitlab");
assert_eq!(gitlab.upstream, "https://gitlab.com/api");
assert_eq!(
gitlab.credential_key,
Some("env://GITLAB_TOKEN".to_string())
);
assert_eq!(gitlab.credential_format.as_deref(), Some("Bearer {}"));
assert_eq!(
gitlab.env_var,
Some("GITLAB_TOKEN".to_string()),
"gitlab credential must have explicit env_var for phantom token"
);
}
#[test]
fn test_claude_code_profile_does_not_enable_credentials_by_default() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let resolved = resolve_network_profile(&policy, "claude-code").expect("should resolve");
assert!(
resolved.profile_credentials.is_empty(),
"network profiles should not implicitly enable credential routes, got: {:?}",
resolved.profile_credentials
);
}
#[test]
fn test_codex_profile_does_not_enable_credentials_by_default() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let resolved = resolve_network_profile(&policy, "codex").expect("should resolve");
assert!(
resolved.profile_credentials.is_empty(),
"network profiles should not implicitly enable credential routes, got: {:?}",
resolved.profile_credentials
);
}
#[test]
fn test_resolve_credentials_rejects_dangerous_env_var() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"evil".to_string(),
CustomCredentialDef {
upstream: "https://api.example.com".to_string(),
credential_key: Some("safe_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: Some("LD_PRELOAD".to_string()),
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let result = resolve_credentials(&policy, &["evil".to_string()], &custom);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("blocklist"),
"should mention blocklist, got: {}",
err
);
}
#[test]
fn test_developer_profile_does_not_enable_github_credential_by_default() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let resolved = resolve_network_profile(&policy, "developer").expect("should resolve");
assert!(
!resolved.profile_credentials.contains(&"github".to_string()),
"developer profile should not include github credential by default, got: {:?}",
resolved.profile_credentials
);
}
#[test]
fn test_developer_profile_does_not_enable_gitlab_credential_by_default() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
let resolved = resolve_network_profile(&policy, "developer").expect("should resolve");
assert!(
!resolved.profile_credentials.contains(&"gitlab".to_string()),
"developer profile should not include gitlab credential by default, got: {:?}",
resolved.profile_credentials
);
}
#[test]
fn test_embedded_network_profiles_do_not_enable_credentials_by_default() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).expect("policy should load");
for profile_name in policy.profiles.keys() {
let resolved =
resolve_network_profile(&policy, profile_name).expect("profile should resolve");
assert!(
resolved.profile_credentials.is_empty(),
"network profile '{}' should not implicitly enable credential routes, got: {:?}",
profile_name,
resolved.profile_credentials
);
}
}
#[test]
fn test_resolve_credentials_with_oauth2_auth() {
use crate::profile::CustomCredentialDef;
use nono_proxy::config::OAuth2Config;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"my_api".to_string(),
CustomCredentialDef {
upstream: "https://api.example.com".to_string(),
credential_key: None,
auth: Some(OAuth2Config {
token_url: "https://auth.example.com/oauth/token".to_string(),
client_id: "my-client".to_string(),
client_secret: "env://CLIENT_SECRET".to_string(),
scope: "api.read".to_string(),
}),
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["my_api".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].prefix, "my_api");
assert_eq!(routes[0].upstream, "https://api.example.com");
assert!(
routes[0].credential_key.is_none(),
"OAuth2 route should not have credential_key"
);
assert!(
routes[0].oauth2.is_some(),
"OAuth2 route should have oauth2 config"
);
let oauth2 = routes[0].oauth2.as_ref().unwrap();
assert_eq!(oauth2.token_url, "https://auth.example.com/oauth/token");
assert_eq!(oauth2.client_id, "my-client");
assert_eq!(oauth2.client_secret, "env://CLIENT_SECRET");
assert_eq!(oauth2.scope, "api.read");
}
#[test]
fn test_resolve_credentials_without_oauth2_has_none() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"standard".to_string(),
CustomCredentialDef {
upstream: "https://api.example.com".to_string(),
credential_key: Some("my_key".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: None,
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &["standard".to_string()], &custom).unwrap();
assert_eq!(routes.len(), 1);
assert!(
routes[0].oauth2.is_none(),
"Non-OAuth2 route should not have oauth2 config"
);
assert_eq!(routes[0].credential_key, Some("my_key".to_string()));
}
#[test]
fn test_collect_allow_domain_port_warnings_detects_host_port_entries() {
let warnings = collect_allow_domain_port_warnings(
&[
"api.example.com".to_string(),
"nats.example.com:4222".to_string(),
"*.corp.internal:8443".to_string(),
],
"allow_domain",
);
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("nats.example.com:4222"));
assert!(warnings[1].contains("*.corp.internal:8443"));
}
#[test]
fn test_collect_allow_domain_port_warnings_ignores_plain_hosts_and_groups() {
let warnings = collect_allow_domain_port_warnings(
&["developer".to_string(), "api.example.com".to_string()],
"allow_domain",
);
assert!(warnings.is_empty());
}
#[test]
fn test_partition_allow_domain_plain_entries() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let entries = vec![
crate::profile::AllowDomainEntry::Plain("api.example.com".to_string()),
crate::profile::AllowDomainEntry::Plain("other.example.com".to_string()),
];
let (plain_hosts, endpoint_routes) = partition_allow_domain(&policy, &entries).unwrap();
assert_eq!(plain_hosts, vec!["api.example.com", "other.example.com"]);
assert!(endpoint_routes.is_empty());
}
#[test]
fn test_partition_allow_domain_with_endpoints() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let entries = vec![
crate::profile::AllowDomainEntry::Plain("api.openai.com".to_string()),
crate::profile::AllowDomainEntry::WithEndpoints {
domain: "api.github.com".to_string(),
endpoints: vec![
EndpointRule {
method: "GET".to_string(),
path: "/repos/my-org/**".to_string(),
},
EndpointRule {
method: "POST".to_string(),
path: "/repos/my-org/*/issues".to_string(),
},
],
},
];
let (plain_hosts, endpoint_routes) = partition_allow_domain(&policy, &entries).unwrap();
assert_eq!(plain_hosts, vec!["api.openai.com"]);
assert_eq!(endpoint_routes.len(), 1);
let route = &endpoint_routes[0];
assert_eq!(route.prefix, "_ep_api.github.com");
assert_eq!(route.upstream, "https://api.github.com");
assert!(route.credential_key.is_none());
assert_eq!(route.endpoint_rules.len(), 2);
assert_eq!(route.endpoint_rules[0].method, "GET");
assert_eq!(route.endpoint_rules[0].path, "/repos/my-org/**");
assert_eq!(route.endpoint_rules[1].method, "POST");
assert_eq!(route.endpoint_rules[1].path, "/repos/my-org/*/issues");
}
#[test]
fn test_partition_allow_domain_empty_endpoints_treated_as_plain() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let entries = vec![crate::profile::AllowDomainEntry::WithEndpoints {
domain: "api.example.com".to_string(),
endpoints: vec![],
}];
let (plain_hosts, endpoint_routes) = partition_allow_domain(&policy, &entries).unwrap();
assert_eq!(plain_hosts, vec!["api.example.com"]);
assert!(endpoint_routes.is_empty());
}
#[test]
fn test_partition_allow_domain_rejects_empty_domain() {
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let entries = vec![crate::profile::AllowDomainEntry::WithEndpoints {
domain: String::new(),
endpoints: vec![EndpointRule {
method: "GET".to_string(),
path: "/**".to_string(),
}],
}];
let result = partition_allow_domain(&policy, &entries);
assert!(result.is_err());
}
#[test]
fn test_resolve_credentials_custom_credentials_without_service_names() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"mockhttp".to_string(),
CustomCredentialDef {
upstream: "https://mockhttp.org".to_string(),
credential_key: Some("env://MOCK_API_KEY".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: Some("MOCK_API_KEY".to_string()),
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &[], &custom).unwrap();
assert_eq!(
routes.len(),
1,
"expected one route for the custom credential, got {}",
routes.len()
);
assert_eq!(routes[0].prefix, "mockhttp");
assert_eq!(routes[0].upstream, "https://mockhttp.org");
assert_eq!(
routes[0].env_var,
Some("MOCK_API_KEY".to_string()),
"env_var must be propagated from CustomCredentialDef"
);
}
#[test]
fn test_resolve_credentials_all_custom_credentials_without_service_names() {
use crate::profile::CustomCredentialDef;
let json = embedded_network_policy_json();
let policy = load_network_policy(json).unwrap();
let mut custom = HashMap::new();
custom.insert(
"svc_a".to_string(),
CustomCredentialDef {
upstream: "https://svc-a.example.com".to_string(),
credential_key: Some("env://SVC_A_KEY".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: Some("SVC_A_KEY".to_string()),
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
custom.insert(
"svc_b".to_string(),
CustomCredentialDef {
upstream: "https://svc-b.example.com".to_string(),
credential_key: Some("env://SVC_B_KEY".to_string()),
auth: None,
inject_mode: InjectMode::Header,
inject_header: "Authorization".to_string(),
credential_format: Some("Bearer {}".to_string()),
path_pattern: None,
path_replacement: None,
query_param_name: None,
proxy: None,
env_var: Some("SVC_B_KEY".to_string()),
endpoint_rules: vec![],
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
aws_auth: None,
},
);
let routes = resolve_credentials(&policy, &[], &custom).unwrap();
assert_eq!(
routes.len(),
2,
"expected one route per custom credential, got {}",
routes.len()
);
let prefixes: Vec<&str> = routes.iter().map(|r| r.prefix.as_str()).collect();
assert!(
prefixes.contains(&"svc_a"),
"route for svc_a must be present; got: {:?}",
prefixes
);
assert!(
prefixes.contains(&"svc_b"),
"route for svc_b must be present; got: {:?}",
prefixes
);
}
}