use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use zeroize::{Zeroize, Zeroizing};
use crate::config;
#[derive(Debug)]
pub struct Profile {
pub vars: BTreeMap<String, String>,
}
impl Profile {
pub fn parse(content: &str, path: &Path) -> Result<Self> {
let vars = parse_env_file(content, path)?;
Ok(Self { vars })
}
pub fn load(name: &str, path: &Path) -> Result<Self> {
let content: Zeroizing<String> = if config::is_encrypted(path) {
gpg_decrypt(path).with_context(|| format!("Failed to decrypt profile '{name}'"))?
} else {
Zeroizing::new(
fs::read_to_string(path)
.with_context(|| format!("Failed to read profile '{name}'"))?,
)
};
Self::parse(&content, path)
}
}
impl Drop for Profile {
fn drop(&mut self) {
for value in self.vars.values_mut() {
value.zeroize();
}
}
}
fn gpg_decrypt(path: &Path) -> Result<Zeroizing<String>> {
let output = Command::new("gpg")
.args(["--quiet", "--batch", "--decrypt"])
.arg(path)
.output()
.context("Failed to run gpg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gpg decryption failed: {stderr}");
}
Ok(Zeroizing::new(
String::from_utf8(output.stdout).context("GPG output is not valid UTF-8")?,
))
}
fn parse_env_file(content: &str, path: &Path) -> Result<BTreeMap<String, String>> {
let mut vars = BTreeMap::new();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1; let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some(eq_pos) = line.find('=') else {
bail!(
"{}:{line_num}: Invalid line (missing '='): {line}",
path.display(),
);
};
let (key, value) = line.split_at(eq_pos);
let key = key.trim();
let value = value[1..].trim();
if key.is_empty() {
bail!("{}:{line_num}: Empty variable name: {line}", path.display(),);
}
if !is_valid_env_name(key) {
bail!(
"{}:{line_num}: Invalid variable name '{key}': must contain only alphanumeric characters and underscores, and not start with a digit",
path.display(),
);
}
let parsed_value = parse_value(value, path, line_num)?;
vars.insert(key.to_string(), parsed_value);
}
Ok(vars)
}
fn is_valid_env_name(name: &str) -> bool {
let mut chars = name.chars();
let is_valid_first = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
is_valid_first && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn parse_value(value: &str, path: &Path, line_num: usize) -> Result<String> {
if value.is_empty() {
return Ok(String::new());
}
let first_char = value.chars().next().unwrap();
let last_char = value.chars().next_back().unwrap();
if first_char == '"' && last_char == '"' && value.len() >= 2 {
let inner = &value[1..value.len() - 1];
return Ok(unescape_double_quoted(inner));
}
if first_char == '\'' && last_char == '\'' && value.len() >= 2 {
return Ok(value[1..value.len() - 1].to_string());
}
if first_char == '"' || first_char == '\'' {
bail!(
"{}:{line_num}: Unclosed quote in value: {value}",
path.display(),
);
}
Ok(value.to_string())
}
fn unescape_double_quoted(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('"') => {
result.push('"');
chars.next();
}
Some('\\') => {
result.push('\\');
chars.next();
}
Some('n') => {
result.push('\n');
chars.next();
}
Some('t') => {
result.push('\t');
chars.next();
}
_ => {
result.push(c);
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_path() -> PathBuf {
PathBuf::from("test.env")
}
#[test]
fn test_parse_simple_values() {
let content = "KEY=value\nANOTHER=123";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
assert_eq!(vars.get("ANOTHER"), Some(&"123".to_string()));
}
#[test]
fn test_parse_comments_and_empty_lines() {
let content = "# This is a comment\nKEY=value\n\n# Another comment\nKEY2=value2";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
assert_eq!(vars.get("KEY2"), Some(&"value2".to_string()));
}
#[test]
fn test_parse_double_quoted_value() {
let content = "KEY=\"value with spaces\"";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("KEY"), Some(&"value with spaces".to_string()));
}
#[test]
fn test_parse_single_quoted_value() {
let content = "KEY='literal $value'";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("KEY"), Some(&"literal $value".to_string()));
}
#[test]
fn test_parse_empty_value() {
let content = "EMPTY=";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("EMPTY"), Some(&String::new()));
}
#[test]
fn test_invalid_line_missing_equals() {
let content = "INVALID_LINE";
let result = parse_env_file(content, &test_path());
assert!(result.is_err());
}
#[test]
fn test_invalid_var_name_starts_with_digit() {
let content = "1INVALID=value";
let result = parse_env_file(content, &test_path());
assert!(result.is_err());
}
#[test]
fn test_valid_var_name_with_underscore() {
let content = "_VALID=value\nALSO_VALID=value2";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("_VALID"), Some(&"value".to_string()));
assert_eq!(vars.get("ALSO_VALID"), Some(&"value2".to_string()));
}
#[test]
fn test_unclosed_quote() {
let content = "KEY=\"unclosed";
let result = parse_env_file(content, &test_path());
assert!(result.is_err());
}
#[test]
fn test_escaped_quotes_in_double_quoted() {
let content = r#"KEY="value with \" escaped quote""#;
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(
vars.get("KEY"),
Some(&"value with \" escaped quote".to_string())
);
}
#[test]
fn test_escape_sequences() {
let content = r#"KEY="line1\nline2\ttabbed\\backslash""#;
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(
vars.get("KEY"),
Some(&"line1\nline2\ttabbed\\backslash".to_string())
);
}
#[test]
fn test_single_quotes_no_escape() {
let content = r"KEY='literal \n not newline'";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(
vars.get("KEY"),
Some(&r"literal \n not newline".to_string())
);
}
#[test]
fn test_utf8_in_values() {
let content = "KEY=café\nKEY2=\"日本語\"";
let vars = parse_env_file(content, &test_path()).unwrap();
assert_eq!(vars.get("KEY"), Some(&"café".to_string()));
assert_eq!(vars.get("KEY2"), Some(&"日本語".to_string()));
}
}