use serde::Serialize;
use std::collections::HashSet;
use std::sync::LazyLock;
pub(crate) const KNOWN_KEYS: &[&str] = &[
"ANYLLM_HOME",
"BACKEND",
"LISTEN_PORT",
"BIG_MODEL",
"SMALL_MODEL",
"RUST_LOG",
"LOG_BODIES",
"PROXY_CONFIG",
"REQUEST_TIMEOUT_SECS",
"MODEL_PRICING_FILE",
"ANYLLM_DEGRADATION_WARNINGS",
"OMIT_STREAM_OPTIONS",
"OPENAI_BASE_URL",
"OPENAI_API_FORMAT",
"OPENAI_API_KEY",
"VERTEX_PROJECT",
"VERTEX_REGION",
"VERTEX_API_KEY",
"GOOGLE_ACCESS_TOKEN",
"GEMINI_BASE_URL",
"GEMINI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_DEPLOYMENT",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_API_VERSION",
"AWS_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"PROXY_API_KEYS",
"PROXY_OPEN_RELAY",
"TLS_CLIENT_CERT_P12",
"TLS_CLIENT_CERT_PASSWORD",
"TLS_CA_CERT",
"IP_ALLOWLIST",
"TRUST_PROXY_HEADERS",
"WEBHOOK_URLS",
"RATE_LIMIT_FAIL_POLICY",
"ADMIN_PORT",
"ADMIN_BIND",
"ADMIN_DB_PATH",
"ADMIN_TOKEN_PATH",
"ADMIN_TOKEN",
"DISABLE_ADMIN",
"WEBUI",
"ADMIN_LOG_RETENTION_DAYS",
"OIDC_ISSUER_URL",
"OIDC_AUDIENCE",
"BATCH_WEBHOOK_URLS",
"BATCH_WEBHOOK_SIGNING_SECRET",
"REDIS_URL",
"QDRANT_URL",
"QDRANT_COLLECTION",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_SERVICE_NAME",
"OTEL_TRACES_SAMPLER",
"OTEL_TRACES_SAMPLER_ARG",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_HOST",
"LITELLM_MASTER_KEY",
"LITELLM_CONFIG",
"AZURE_API_KEY",
"AZURE_API_BASE",
"AZURE_API_VERSION",
"AWS_REGION_NAME",
"LITELLM_IP_ALLOWLIST",
];
const SENSITIVE_KEYS: &[&str] = &["ADMIN_TOKEN", "ADMIN_TOKEN_PATH"];
static KNOWN_KEYS_SET: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| KNOWN_KEYS.iter().copied().collect());
#[derive(Debug, Clone, Serialize)]
pub struct ParsedPair {
pub key: String,
pub value: String,
pub line: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct EnvWarning {
pub line: Option<usize>,
pub key: Option<String>,
pub message: String,
}
#[derive(Debug)]
pub struct ParseResult {
pub pairs: Vec<ParsedPair>,
pub warnings: Vec<EnvWarning>,
pub hard_errors: Vec<String>,
}
fn unescape_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
pub fn escape_for_env_file(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
other => out.push(other),
}
}
out
}
pub fn parse_env_content(content: &str) -> ParseResult {
let mut pairs: Vec<ParsedPair> = Vec::new();
let mut warnings: Vec<EnvWarning> = Vec::new();
let mut hard_errors: Vec<String> = Vec::new();
let mut seen_keys: HashSet<String> = HashSet::new();
if content.contains('\0') {
hard_errors.push("file contains binary content (null bytes)".to_string());
return ParseResult {
pairs,
warnings,
hard_errors,
};
}
let mut had_content = false;
for (idx, raw) in content.lines().enumerate() {
let lineno = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
had_content = true;
if line.len() > 4096 {
warnings.push(EnvWarning {
line: Some(lineno),
key: None,
message: format!("line {} exceeds 4096 characters, skipping", lineno),
});
continue;
}
let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
let Some((raw_key, val)) = line.split_once('=') else {
warnings.push(EnvWarning {
line: Some(lineno),
key: None,
message: format!("line {} has no '=', skipping", lineno),
});
continue;
};
let key = raw_key.trim();
if key.is_empty() {
continue;
}
if !is_valid_key(key) {
hard_errors.push(format!(
"line {lineno}: key {key:?} is not a valid env var name \
(must match [A-Z_][A-Z0-9_]*)"
));
continue;
}
let val = val.trim();
let value: String = if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
unescape_double_quoted(&val[1..val.len() - 1])
} else if val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2 {
val[1..val.len() - 1].to_string()
} else {
val.to_string()
};
if value.is_empty() {
warnings.push(EnvWarning {
line: Some(lineno),
key: Some(key.to_string()),
message: format!("key {key:?} has an empty value"),
});
}
if value.len() > 2048 {
warnings.push(EnvWarning {
line: Some(lineno),
key: Some(key.to_string()),
message: format!(
"key {key:?} value is {} chars (> 2048), verify it is correct",
value.len()
),
});
}
if !KNOWN_KEYS_SET.contains(key) {
warnings.push(EnvWarning {
line: Some(lineno),
key: Some(key.to_string()),
message: format!("key {key:?} is not a recognized anyllm-proxy variable"),
});
}
if SENSITIVE_KEYS.contains(&key) {
warnings.push(EnvWarning {
line: Some(lineno),
key: Some(key.to_string()),
message: format!("key {key:?} is sensitive — verify the value is intentional"),
});
}
if seen_keys.contains(key) {
warnings.push(EnvWarning {
line: Some(lineno),
key: Some(key.to_string()),
message: format!("key {key:?} appears more than once; last value wins"),
});
} else {
seen_keys.insert(key.to_string());
}
pairs.push(ParsedPair {
key: key.to_string(),
value,
line: lineno,
});
}
if had_content && pairs.is_empty() && hard_errors.is_empty() {
hard_errors
.push("no valid KEY=VALUE entries found — is this a .anyllm.env file?".to_string());
}
ParseResult {
pairs,
warnings,
hard_errors,
}
}
fn is_valid_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c.is_ascii_uppercase() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_pairs() {
let result = parse_env_content("BACKEND=openai\nRUST_LOG=info\n");
assert!(result.hard_errors.is_empty());
assert_eq!(result.pairs.len(), 2);
assert_eq!(result.pairs[0].key, "BACKEND");
assert_eq!(result.pairs[0].value, "openai");
}
#[test]
fn parse_double_quoted_escapes() {
let result = parse_env_content(r#"MY_KEY="hello\nworld""#);
assert!(result.hard_errors.is_empty());
assert_eq!(result.pairs[0].value, "hello\nworld");
}
#[test]
fn parse_single_quoted_literal() {
let result = parse_env_content("MY_KEY='hello\\nworld'");
assert!(result.hard_errors.is_empty());
assert_eq!(result.pairs[0].value, "hello\\nworld");
}
#[test]
fn parse_export_prefix() {
let result = parse_env_content("export BACKEND=openai");
assert!(result.hard_errors.is_empty());
assert_eq!(result.pairs[0].key, "BACKEND");
}
#[test]
fn parse_comments_and_blanks_ignored() {
let result = parse_env_content("# comment\n\nBACKEND=openai\n");
assert_eq!(result.pairs.len(), 1);
}
#[test]
fn hard_reject_null_bytes() {
let content = "BACKEND=openai\x00";
let result = parse_env_content(content);
assert!(!result.hard_errors.is_empty());
assert!(result.hard_errors[0].contains("binary content"));
}
#[test]
fn hard_reject_invalid_key_with_dash() {
let result = parse_env_content("KEY-WITH-DASH=value");
assert!(!result.hard_errors.is_empty());
assert!(result.hard_errors[0].contains("KEY-WITH-DASH"));
}
#[test]
fn hard_reject_lowercase_key() {
let result = parse_env_content("mykey=value");
assert!(!result.hard_errors.is_empty());
}
#[test]
fn hard_reject_empty_file_with_content() {
let result = parse_env_content("# just a comment\n");
assert!(result.hard_errors.is_empty());
}
#[test]
fn hard_reject_no_valid_pairs_from_content() {
let result = parse_env_content("not_an_env_var_line\n");
assert!(!result.hard_errors.is_empty());
assert!(result.hard_errors[0].contains("no valid KEY=VALUE"));
}
#[test]
fn warn_unknown_key() {
let result = parse_env_content("UNKNOWN_XYZ_KEY=value");
assert!(result.hard_errors.is_empty());
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("not a recognized")));
}
#[test]
fn warn_duplicate_key() {
let result = parse_env_content("BACKEND=openai\nBACKEND=vertex\n");
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("more than once")));
assert_eq!(result.pairs.last().unwrap().value, "vertex");
}
#[test]
fn warn_empty_value() {
let result = parse_env_content("BACKEND=");
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("empty value")));
}
#[test]
fn warn_sensitive_key() {
let result = parse_env_content("ADMIN_TOKEN=secret");
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("sensitive")));
}
#[test]
fn escape_for_env_file_basic() {
assert_eq!(escape_for_env_file(r#"say "hi""#), r#"say \"hi\""#);
assert_eq!(escape_for_env_file("line1\nline2"), "line1\\nline2");
assert_eq!(escape_for_env_file("back\\slash"), "back\\\\slash");
}
#[test]
fn is_valid_key_checks() {
assert!(is_valid_key("BACKEND"));
assert!(is_valid_key("_PRIVATE"));
assert!(is_valid_key("KEY_123"));
assert!(!is_valid_key("key"));
assert!(!is_valid_key("KEY-DASH"));
assert!(!is_valid_key("123KEY"));
assert!(!is_valid_key(""));
}
#[test]
fn value_with_equals_sign() {
let result = parse_env_content("BACKEND=open=ai");
assert!(result.hard_errors.is_empty());
assert_eq!(result.pairs[0].value, "open=ai");
}
}