use std::path::Path;
use zeroize::Zeroizing;
use crate::error::SecretshError;
#[derive(Debug)]
pub struct EnvEntry {
pub key: String,
pub value: Zeroizing<Vec<u8>>,
pub line: usize,
}
pub fn parse_dotenv(path: &Path) -> Result<Vec<EnvEntry>, SecretshError> {
let content =
std::fs::read_to_string(path).map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
parse_dotenv_str(&content)
}
pub fn parse_dotenv_str(content: &str) -> Result<Vec<EnvEntry>, SecretshError> {
let mut entries = Vec::new();
for (idx, raw_line) in content.lines().enumerate() {
let line_num = idx + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line
.strip_prefix("export ")
.or_else(|| line.strip_prefix("export\t"))
.unwrap_or(line);
let eq_pos = line.find('=').ok_or_else(|| {
SecretshError::Io(crate::error::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(".env line {line_num}: missing '=' delimiter"),
)))
})?;
let key = line[..eq_pos].trim().to_owned();
let raw_value = &line[eq_pos + 1..];
let value = parse_value(raw_value, line_num)?;
entries.push(EnvEntry {
key,
value: Zeroizing::new(value),
line: line_num,
});
}
Ok(entries)
}
fn parse_value(raw: &str, line_num: usize) -> Result<Vec<u8>, SecretshError> {
let trimmed = raw.trim_start();
if trimmed.starts_with('"') {
parse_double_quoted(trimmed, line_num)
} else if trimmed.starts_with('\'') {
parse_single_quoted(trimmed, line_num)
} else {
let value = strip_inline_comment(trimmed);
Ok(value.as_bytes().to_vec())
}
}
fn parse_double_quoted(s: &str, line_num: usize) -> Result<Vec<u8>, SecretshError> {
let inner = &s[1..]; let mut result = Vec::new();
let mut chars = inner.chars();
loop {
match chars.next() {
None => {
return Err(SecretshError::Io(crate::error::IoError(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(".env line {line_num}: unterminated double-quoted value"),
),
)));
}
Some('"') => break, Some('\\') => {
match chars.next() {
Some('n') => result.push(b'\n'),
Some('t') => result.push(b'\t'),
Some('r') => result.push(b'\r'),
Some('"') => result.push(b'"'),
Some('\\') => result.push(b'\\'),
Some(c) => {
result.push(b'\\');
let mut buf = [0u8; 4];
result.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
None => {
return Err(SecretshError::Io(crate::error::IoError(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
".env line {line_num}: trailing backslash in double-quoted value"
),
),
)));
}
}
}
Some(c) => {
let mut buf = [0u8; 4];
result.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
}
}
Ok(result)
}
fn parse_single_quoted(s: &str, line_num: usize) -> Result<Vec<u8>, SecretshError> {
let inner = &s[1..]; let end = inner.find('\'').ok_or_else(|| {
SecretshError::Io(crate::error::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(".env line {line_num}: unterminated single-quoted value"),
)))
})?;
Ok(inner.as_bytes()[..end].to_vec())
}
fn strip_inline_comment(s: &str) -> &str {
if let Some(pos) = s.find(" #") {
s[..pos].trim_end()
} else {
s.trim_end()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &str) -> Vec<EnvEntry> {
parse_dotenv_str(input).expect("parse failed")
}
fn keys_values(entries: &[EnvEntry]) -> Vec<(&str, &[u8])> {
entries
.iter()
.map(|e| (e.key.as_str(), e.value.as_slice()))
.collect()
}
#[test]
fn simple_key_value() {
let entries = parse("API_KEY=hunter2\n");
assert_eq!(
keys_values(&entries),
vec![("API_KEY", b"hunter2" as &[u8])]
);
}
#[test]
fn multiple_entries() {
let entries = parse("A=1\nB=2\nC=3\n");
assert_eq!(entries.len(), 3);
assert_eq!(keys_values(&entries)[0], ("A", b"1" as &[u8]));
assert_eq!(keys_values(&entries)[2], ("C", b"3" as &[u8]));
}
#[test]
fn empty_value() {
let entries = parse("KEY=\n");
assert_eq!(keys_values(&entries), vec![("KEY", b"" as &[u8])]);
}
#[test]
fn comment_lines_ignored() {
let entries = parse("# This is a comment\nKEY=val\n# Another comment\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].key, "KEY");
}
#[test]
fn inline_comment_stripped() {
let entries = parse("KEY=value # this is a comment\n");
assert_eq!(keys_values(&entries), vec![("KEY", b"value" as &[u8])]);
}
#[test]
fn hash_in_value_without_leading_space_preserved() {
let entries = parse("KEY=abc#def\n");
assert_eq!(keys_values(&entries), vec![("KEY", b"abc#def" as &[u8])]);
}
#[test]
fn blank_lines_ignored() {
let entries = parse("\n\nKEY=val\n\n");
assert_eq!(entries.len(), 1);
}
#[test]
fn whitespace_only_lines_ignored() {
let entries = parse(" \n\t\nKEY=val\n");
assert_eq!(entries.len(), 1);
}
#[test]
fn export_prefix_stripped() {
let entries = parse("export API_KEY=secret\n");
assert_eq!(keys_values(&entries), vec![("API_KEY", b"secret" as &[u8])]);
}
#[test]
fn export_with_tab() {
let entries = parse("export\tAPI_KEY=secret\n");
assert_eq!(keys_values(&entries), vec![("API_KEY", b"secret" as &[u8])]);
}
#[test]
fn double_quoted_value() {
let entries = parse(r#"KEY="hello world""#);
assert_eq!(
keys_values(&entries),
vec![("KEY", b"hello world" as &[u8])]
);
}
#[test]
fn double_quoted_with_escapes() {
let entries = parse(r#"KEY="line1\nline2""#);
assert_eq!(
keys_values(&entries),
vec![("KEY", b"line1\nline2" as &[u8])]
);
}
#[test]
fn double_quoted_with_escaped_quote() {
let entries = parse(r#"KEY="say \"hi\"""#);
assert_eq!(keys_values(&entries), vec![("KEY", b"say \"hi\"" as &[u8])]);
}
#[test]
fn double_quoted_preserves_hash() {
let entries = parse(r#"KEY="value # not a comment""#);
assert_eq!(
keys_values(&entries),
vec![("KEY", b"value # not a comment" as &[u8])]
);
}
#[test]
fn unterminated_double_quote_is_error() {
let result = parse_dotenv_str(r#"KEY="unclosed"#);
assert!(result.is_err());
}
#[test]
fn single_quoted_value() {
let entries = parse("KEY='hello world'\n");
assert_eq!(
keys_values(&entries),
vec![("KEY", b"hello world" as &[u8])]
);
}
#[test]
fn single_quoted_no_escape_processing() {
let entries = parse(r"KEY='no\nescape'");
assert_eq!(
keys_values(&entries),
vec![("KEY", br"no\nescape" as &[u8])]
);
}
#[test]
fn single_quoted_preserves_hash() {
let entries = parse("KEY='value # not a comment'\n");
assert_eq!(
keys_values(&entries),
vec![("KEY", b"value # not a comment" as &[u8])]
);
}
#[test]
fn unterminated_single_quote_is_error() {
let result = parse_dotenv_str("KEY='unclosed");
assert!(result.is_err());
}
#[test]
fn missing_equals_is_error() {
let result = parse_dotenv_str("NOEQUALS\n");
assert!(result.is_err());
}
#[test]
fn line_numbers_are_correct() {
let entries = parse("# comment\n\nA=1\n# another\nB=2\n");
assert_eq!(entries[0].line, 3);
assert_eq!(entries[1].line, 5);
}
#[test]
fn trailing_whitespace_on_unquoted_stripped() {
let entries = parse("KEY=value \n");
assert_eq!(keys_values(&entries), vec![("KEY", b"value" as &[u8])]);
}
#[test]
fn value_with_equals_sign() {
let entries = parse("KEY=abc=def\n");
assert_eq!(keys_values(&entries), vec![("KEY", b"abc=def" as &[u8])]);
}
}