use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub const CLOUDFLARE_ACCOUNT_ID_ENV_KEYS: &[&str] =
&["CLOUDFLARE_ACCOUNT_ID", "XBP_CLOUDFLARE_ACCOUNT_ID"];
pub fn strip_utf8_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
pub fn normalize_env_key(raw: &str) -> String {
let stripped = strip_utf8_bom(raw.trim());
stripped
.chars()
.filter(|ch| {
!matches!(
ch,
'\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
)
})
.collect::<String>()
.trim()
.to_string()
}
pub fn normalize_env_value(raw: &str) -> String {
let trimmed = strip_utf8_bom(raw.trim());
if trimmed.is_empty() {
return String::new();
}
match detect_quote_style(trimmed) {
QuoteStyle::Double => {
let inner = unwrap_outer_quotes(trimmed, '"');
expand_double_quoted_escapes(&inner)
}
QuoteStyle::Single => {
let inner = unwrap_outer_quotes(trimmed, '\'');
unwrap_full_double_wrap_if_present(&inner)
}
QuoteStyle::Unquoted => trimmed.to_string(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QuoteStyle {
Double,
Single,
Unquoted,
}
fn detect_quote_style(value: &str) -> QuoteStyle {
let mut chars = value.chars();
match chars.next() {
Some('"') => QuoteStyle::Double,
Some('\'') => QuoteStyle::Single,
_ => QuoteStyle::Unquoted,
}
}
fn unwrap_outer_quotes(value: &str, quote: char) -> String {
let trimmed = value.trim();
if trimmed.len() >= 2 && trimmed.starts_with(quote) && ends_with_unescaped_quote(trimmed, quote)
{
trimmed[quote.len_utf8()..trimmed.len() - quote.len_utf8()].to_string()
} else if trimmed.len() >= 2 && trimmed.starts_with(quote) && trimmed.ends_with(quote) {
trimmed[quote.len_utf8()..trimmed.len() - quote.len_utf8()].to_string()
} else {
trimmed.to_string()
}
}
fn unwrap_full_double_wrap_if_present(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() >= 2
&& trimmed.starts_with('"')
&& ends_with_unescaped_quote(trimmed, '"')
&& is_fully_double_quoted(trimmed)
{
expand_double_quoted_escapes(&trimmed[1..trimmed.len() - 1])
} else {
trimmed.to_string()
}
}
fn is_fully_double_quoted(value: &str) -> bool {
if !value.starts_with('"') || value.len() < 2 {
return false;
}
match find_closing_quote_index(value, '"') {
Some(end) => end + 1 == value.len(),
None => false,
}
}
fn ends_with_unescaped_quote(value: &str, quote: char) -> bool {
if !value.ends_with(quote) {
return false;
}
if quote != '"' {
return true;
}
let bytes = value.as_bytes();
let mut slash_count = 0usize;
for byte in bytes[..bytes.len() - 1].iter().rev() {
if *byte == b'\\' {
slash_count += 1;
} else {
break;
}
}
slash_count % 2 == 0
}
fn expand_double_quoted_escapes(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('0') => out.push('\0'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('\'') => out.push('\''),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out
}
pub fn is_env_comment_line(line: &str) -> bool {
let trimmed = strip_utf8_bom(line.trim());
trimmed.is_empty() || trimmed.starts_with('#')
}
pub fn parse_env_content(content: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
let mut lines = strip_utf8_bom(content).lines().peekable();
while let Some(line) = lines.next() {
if is_env_comment_line(line) {
continue;
}
let mut trimmed = strip_utf8_bom(line.trim());
if let Some(rest) = trimmed
.strip_prefix("export ")
.or_else(|| trimmed.strip_prefix("export\t"))
{
trimmed = rest.trim();
if is_env_comment_line(trimmed) {
continue;
}
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = normalize_env_key(key);
if key.is_empty() || key.starts_with('#') {
continue;
}
let raw_value = collect_env_value(value, &mut lines);
result.insert(key, normalize_env_value(&raw_value));
}
result
}
fn collect_env_value<'a, I>(first_line_value: &str, lines: &mut std::iter::Peekable<I>) -> String
where
I: Iterator<Item = &'a str>,
{
let leading = first_line_value.to_string();
let trimmed_start = leading.trim_start();
let Some(quote) = starts_with_quote(trimmed_start) else {
return strip_unquoted_inline_comment(first_line_value.trim()).to_string();
};
let mut raw = leading;
while !quoted_value_is_complete(&raw, quote) {
let Some(next_line) = lines.next() else {
break;
};
raw.push('\n');
raw.push_str(next_line);
}
raw
}
fn strip_unquoted_inline_comment(value: &str) -> &str {
match value.find(" #") {
Some(idx) => value[..idx].trim_end(),
None => value,
}
}
fn starts_with_quote(value: &str) -> Option<char> {
value
.chars()
.next()
.filter(|quote| *quote == '"' || *quote == '\'')
}
fn quoted_value_is_complete(value: &str, quote: char) -> bool {
let trimmed = value.trim_start();
if !trimmed.starts_with(quote) {
return true;
}
find_closing_quote_index(trimmed, quote).is_some()
}
fn find_closing_quote_index(value: &str, quote: char) -> Option<usize> {
let mut chars = value.char_indices();
let Some((_, first)) = chars.next() else {
return None;
};
if first != quote {
return None;
}
let mut escaped = false;
for (idx, ch) in chars {
if quote == '"' {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
}
if ch == quote {
return Some(idx);
}
}
None
}
pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
Ok(parse_env_content(&content))
}
pub fn to_env_references(vars: &HashMap<String, String>) -> HashMap<String, String> {
vars.keys()
.map(|key| (key.clone(), format!("${{{}}}", key)))
.collect()
}
pub fn resolve_env_placeholders(
project_root: &Path,
envs: &HashMap<String, String>,
) -> HashMap<String, String> {
let lookup = load_env_lookup(project_root);
envs.iter()
.map(|(key, value)| {
let resolved = env_reference_name(value)
.and_then(|name| lookup.get(name).cloned())
.unwrap_or_else(|| value.clone());
(key.clone(), resolved)
})
.collect()
}
pub fn first_lookup_value(lookup: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| lookup.get(*key))
.map(|value| normalize_env_value(value))
.filter(|value| !value.is_empty())
}
pub fn load_env_lookup(project_root: &Path) -> HashMap<String, String> {
let mut lookup = HashMap::new();
for name in [".env", ".env.local", ".env.development", ".env.production"] {
let path = project_root.join(name);
if !path.exists() {
continue;
}
if let Ok(parsed) = parse_env_file(&path) {
lookup.extend(parsed);
}
}
lookup.extend(std::env::vars());
lookup
}
fn env_reference_name(value: &str) -> Option<&str> {
let trimmed = value.trim();
if let Some(name) = trimmed
.strip_prefix("${")
.and_then(|rest| rest.strip_suffix('}'))
{
return (!name.trim().is_empty()).then_some(name.trim());
}
trimmed
.strip_prefix('$')
.map(str::trim)
.filter(|name| !name.is_empty())
}
#[cfg(test)]
mod tests {
use super::{
expand_double_quoted_escapes, is_env_comment_line, normalize_env_key, normalize_env_value,
parse_env_content, resolve_env_placeholders, to_env_references,
};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
fn make_temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-env-files-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir should be created");
dir
}
#[test]
fn normalize_env_value_strips_redundant_wrapping_quotes() {
assert_eq!(normalize_env_value(r#""hello""#), "hello");
assert_eq!(normalize_env_value("'hello'"), "hello");
assert_eq!(normalize_env_value(r#"'\"hello\"'"#), r#"\"hello\""#);
assert_eq!(normalize_env_value("''hello''"), "'hello'");
assert_eq!(normalize_env_value("hello"), "hello");
}
#[test]
fn normalize_env_value_expands_double_quoted_escapes() {
assert_eq!(normalize_env_value(r#""line1\nline2""#), "line1\nline2");
assert_eq!(normalize_env_value(r#""a\tb\r\nc\\d""#), "a\tb\r\nc\\d");
assert_eq!(normalize_env_value(r#""say \"hi\"""#), "say \"hi\"");
assert_eq!(
expand_double_quoted_escapes(r#"hello\nworld"#),
"hello\nworld"
);
}
#[test]
fn normalize_env_value_keeps_single_quoted_json_literal() {
let raw = r#"'{"column": "value", "column2": "value2"}'"#;
assert_eq!(
normalize_env_value(raw),
r#"{"column": "value", "column2": "value2"}"#
);
assert_eq!(
normalize_env_value(r#"'{"note":"a\nb"}'"#),
r#"{"note":"a\nb"}"#
);
}
#[test]
fn parse_env_content_supports_json_amount_style_values() {
let parsed = parse_env_content(
r#"
AMOUNT='{"currency":"EUR","value":"10.00"}'
NESTED='{"column": "value", "column2": "value2"}'
ESCAPED_JSON="{\"currency\":\"EUR\",\"value\":\"10.00\"}"
CURRENCY="EUR"
"#,
);
assert_eq!(
parsed.get("AMOUNT"),
Some(&r#"{"currency":"EUR","value":"10.00"}"#.to_string())
);
assert_eq!(
parsed.get("NESTED"),
Some(&r#"{"column": "value", "column2": "value2"}"#.to_string())
);
assert_eq!(
parsed.get("ESCAPED_JSON"),
Some(&r#"{"currency":"EUR","value":"10.00"}"#.to_string())
);
assert_eq!(parsed.get("CURRENCY"), Some(&"EUR".to_string()));
}
#[test]
fn parse_env_content_supports_multiline_json() {
let parsed = parse_env_content(
r#"
AMOUNT='{
"currency": "EUR",
"value": "10.00"
}'
NEXT=ok
"#,
);
let amount = parsed.get("AMOUNT").expect("AMOUNT");
assert!(amount.contains("\"currency\": \"EUR\""));
assert!(amount.contains("\"value\": \"10.00\""));
assert!(amount.starts_with('{'));
assert!(amount.ends_with('}'));
assert_eq!(parsed.get("NEXT"), Some(&"ok".to_string()));
}
#[test]
fn parse_env_content_expands_double_quoted_newlines() {
let parsed = parse_env_content(
r#"
NOTE="hello\nworld"
MULTI="line1
line2"
"#,
);
assert_eq!(parsed.get("NOTE"), Some(&"hello\nworld".to_string()));
assert_eq!(parsed.get("MULTI"), Some(&"line1\nline2".to_string()));
}
#[test]
fn parse_env_content_normalizes_quotes_and_exports() {
let parsed = parse_env_content(
r#"
export FIRST='"hello"'
SECOND='world'
THIRD=plain
"#,
);
assert_eq!(parsed.get("FIRST"), Some(&"hello".to_string()));
assert_eq!(parsed.get("SECOND"), Some(&"world".to_string()));
assert_eq!(parsed.get("THIRD"), Some(&"plain".to_string()));
}
#[test]
fn parse_env_content_dismisses_full_line_hash_comments() {
assert!(is_env_comment_line("# pure comment"));
assert!(is_env_comment_line(" # indented comment"));
assert!(is_env_comment_line("\u{feff}# bom comment"));
assert!(!is_env_comment_line("KEY=value"));
assert!(!is_env_comment_line("KEY=#not-a-line-comment"));
let parsed = parse_env_content(
r#"
# EXAMPLE DRIVEN VARS
# PROFILE_ID="pfl_YwS9pVTnEy" # disabled entirely
# indented comment with KEY=looks_like_assignment
export # bare export comment
MOLLIE_API_KEY="live_xxx"
CURRENCY="EUR"
# TRAILING=should_not_parse
"#,
);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed.get("MOLLIE_API_KEY"), Some(&"live_xxx".to_string()));
assert_eq!(parsed.get("CURRENCY"), Some(&"EUR".to_string()));
assert!(!parsed.contains_key("PROFILE_ID"));
assert!(!parsed.contains_key("TRAILING"));
assert!(!parsed.contains_key("KEY"));
}
#[test]
fn parse_env_content_strips_utf8_bom_from_first_key() {
let parsed = parse_env_content("\u{feff}MOLLIE_API_KEY=\"secret\"\nCURRENCY=EUR\n");
assert!(parsed.contains_key("MOLLIE_API_KEY"));
assert!(!parsed.keys().any(|k| k.starts_with('\u{feff}')));
assert_eq!(parsed.get("MOLLIE_API_KEY"), Some(&"secret".to_string()));
assert_eq!(parsed.get("CURRENCY"), Some(&"EUR".to_string()));
}
#[test]
fn normalize_env_key_strips_bom_and_zero_width() {
assert_eq!(normalize_env_key("\u{feff}MOLLIE_API_KEY"), "MOLLIE_API_KEY");
assert_eq!(
normalize_env_key("MOL\u{200b}LIE_API_KEY"),
"MOLLIE_API_KEY"
);
}
#[test]
fn parse_env_content_preserves_multiline_quoted_values() {
let parsed = parse_env_content(
r#"APP_ID="2995603"
APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
line-1
line-2
-----END RSA PRIVATE KEY-----"
DISABLE_AUTO_UPDATE="true""#,
);
assert_eq!(parsed.get("APP_ID"), Some(&"2995603".to_string()));
assert_eq!(
parsed.get("APP_PRIVATE_KEY"),
Some(
&"-----BEGIN RSA PRIVATE KEY-----\nline-1\nline-2\n-----END RSA PRIVATE KEY-----"
.to_string()
)
);
assert_eq!(parsed.get("DISABLE_AUTO_UPDATE"), Some(&"true".to_string()));
}
#[test]
fn parse_env_content_preserves_multiline_exported_values() {
let parsed = parse_env_content(
r#"export GITHUB_APP_PRIVATE_KEY="-----BEGIN KEY-----
abc123
-----END KEY-----""#,
);
assert_eq!(
parsed.get("GITHUB_APP_PRIVATE_KEY"),
Some(&"-----BEGIN KEY-----\nabc123\n-----END KEY-----".to_string())
);
}
#[test]
fn to_env_references_maps_values_to_placeholders() {
let mut vars = HashMap::new();
vars.insert("DATABASE_URL".to_string(), "postgres://demo".to_string());
let refs = to_env_references(&vars);
assert_eq!(
refs.get("DATABASE_URL"),
Some(&"${DATABASE_URL}".to_string())
);
}
#[test]
fn first_lookup_value_reads_cloudflare_account_id_aliases() {
use super::{first_lookup_value, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS};
let mut lookup = HashMap::new();
lookup.insert(
"XBP_CLOUDFLARE_ACCOUNT_ID".to_string(),
"acc-from-xbp-key".to_string(),
);
assert_eq!(
first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS).as_deref(),
Some("acc-from-xbp-key")
);
}
#[test]
fn resolve_env_placeholders_reads_local_env_files() {
let project_root = make_temp_dir("resolve-placeholders");
fs::write(
project_root.join(".env.local"),
"DATABASE_URL='postgres://demo'\nAPI_KEY='\"secret\"'\n",
)
.expect("env file should be written");
let mut envs = HashMap::new();
envs.insert("DATABASE_URL".to_string(), "${DATABASE_URL}".to_string());
envs.insert("API_KEY".to_string(), "${API_KEY}".to_string());
envs.insert("NODE_ENV".to_string(), "production".to_string());
let resolved = resolve_env_placeholders(&project_root, &envs);
assert_eq!(
resolved.get("DATABASE_URL"),
Some(&"postgres://demo".to_string())
);
assert_eq!(resolved.get("API_KEY"), Some(&"secret".to_string()));
assert_eq!(resolved.get("NODE_ENV"), Some(&"production".to_string()));
let _ = fs::remove_dir_all(project_root);
}
}