open_sesame/util/
env.rs

1//! Environment file parsing utilities
2//!
3//! Supports direnv-style .env files with layered loading.
4
5use crate::util::{Error, Result};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// Expand ~ to home directory in path
10pub fn expand_path(path: &str) -> PathBuf {
11    if let Some(rest) = path.strip_prefix("~/")
12        && let Some(home) = dirs::home_dir()
13    {
14        return home.join(rest);
15    }
16    PathBuf::from(path)
17}
18
19/// Parse a .env file (direnv style) and return key-value pairs
20///
21/// Supports:
22/// - KEY=value
23/// - KEY="value with spaces"
24/// - KEY='value with spaces'
25/// - export KEY=value
26/// - # comments
27/// - Empty lines
28pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
29    let content = std::fs::read_to_string(path).map_err(|source| Error::ConfigRead {
30        path: path.to_path_buf(),
31        source,
32    })?;
33
34    let mut env = HashMap::new();
35
36    for (line_num, line) in content.lines().enumerate() {
37        let line = line.trim();
38
39        // Empty lines and comments skipped
40        if line.is_empty() || line.starts_with('#') {
41            continue;
42        }
43
44        // Optional 'export ' prefix stripped
45        let line = line.strip_prefix("export ").unwrap_or(line);
46
47        // Find the = separator
48        let Some(eq_pos) = line.find('=') else {
49            tracing::warn!(
50                "{}:{}: Invalid line (no '='): {}",
51                path.display(),
52                line_num + 1,
53                line
54            );
55            continue;
56        };
57
58        let key = line[..eq_pos].trim().to_string();
59        let value_raw = line[eq_pos + 1..].trim();
60
61        // Parse the value (handle quotes)
62        let value = parse_env_value(value_raw);
63
64        if !key.is_empty() {
65            env.insert(key, value);
66        }
67    }
68
69    Ok(env)
70}
71
72/// Returns whether value contains potentially dangerous shell metacharacters.
73fn contains_shell_metacharacters(value: &str) -> bool {
74    value
75        .chars()
76        .any(|c| matches!(c, '$' | '`' | '|' | ';' | '&' | '<' | '>' | '\n' | '\r'))
77}
78
79/// Parses environment variable value, handling single/double quotes.
80fn parse_env_value(raw: &str) -> String {
81    let raw = raw.trim();
82
83    // Double-quoted value processing
84    if raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2 {
85        let value = raw[1..raw.len() - 1]
86            .replace("\\n", "\n")
87            .replace("\\t", "\t")
88            .replace("\\\"", "\"")
89            .replace("\\\\", "\\");
90
91        if contains_shell_metacharacters(&value) {
92            tracing::warn!("Environment value contains shell metacharacters: {}", raw);
93        }
94
95        return value;
96    }
97
98    // Single-quoted value (no escape processing applied)
99    if raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2 {
100        let value = raw[1..raw.len() - 1].to_string();
101
102        if contains_shell_metacharacters(&value) {
103            tracing::warn!("Environment value contains shell metacharacters: {}", raw);
104        }
105
106        return value;
107    }
108
109    // Unquoted value with inline comments stripped
110    let value = if let Some(comment_pos) = raw.find(" #") {
111        raw[..comment_pos].trim().to_string()
112    } else {
113        raw.to_string()
114    };
115
116    if contains_shell_metacharacters(&value) {
117        tracing::warn!("Environment value contains shell metacharacters: {}", raw);
118    }
119
120    value
121}
122
123/// Loads environment variables from list of env files.
124///
125/// Later files override earlier ones.
126pub fn load_env_files(paths: &[String]) -> HashMap<String, String> {
127    let mut env = HashMap::new();
128
129    for path_str in paths {
130        let path = expand_path(path_str);
131        if !path.exists() {
132            tracing::debug!("Env file not found (skipping): {:?}", path);
133            continue;
134        }
135
136        match parse_env_file(&path) {
137            Ok(file_env) => {
138                tracing::debug!("Loaded {} vars from {:?}", file_env.len(), path);
139                env.extend(file_env);
140            }
141            Err(e) => {
142                tracing::warn!("Failed to parse env file {:?}: {}", path, e);
143            }
144        }
145    }
146
147    env
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_parse_env_value_unquoted() {
156        assert_eq!(parse_env_value("hello"), "hello");
157        assert_eq!(parse_env_value("  hello  "), "hello");
158    }
159
160    #[test]
161    fn test_parse_env_value_double_quoted() {
162        assert_eq!(parse_env_value(r#""hello world""#), "hello world");
163        assert_eq!(parse_env_value(r#""line1\nline2""#), "line1\nline2");
164        assert_eq!(parse_env_value(r#""tab\there""#), "tab\there");
165        assert_eq!(parse_env_value(r#""escaped\"quote""#), "escaped\"quote");
166    }
167
168    #[test]
169    fn test_parse_env_value_single_quoted() {
170        assert_eq!(parse_env_value("'hello world'"), "hello world");
171        assert_eq!(parse_env_value(r"'no\nescapes'"), r"no\nescapes");
172    }
173
174    #[test]
175    fn test_parse_env_value_inline_comment() {
176        assert_eq!(parse_env_value("value # comment"), "value");
177    }
178
179    #[test]
180    fn test_expand_path() {
181        // Non-tilde paths remain unchanged
182        assert_eq!(expand_path("/usr/bin"), PathBuf::from("/usr/bin"));
183        assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
184
185        // Tilde expansion when home dir exists
186        if let Some(home) = dirs::home_dir() {
187            assert_eq!(expand_path("~/test"), home.join("test"));
188            assert_eq!(expand_path("~/.config/app"), home.join(".config/app"));
189        }
190    }
191}