Skip to main content

keyhog_scanner/structured/parsers/
env.rs

1use super::ExtractedPair;
2
3/// Parse KEY=VALUE lines from an .env file.
4///
5/// Quoting styles recognised:
6/// - `KEY="value"` and `KEY='value'` (matching ASCII single/double quotes).
7/// - `` KEY=`value` `` backtick-quoted bodies (some shells + dotenv-cli
8///   accept these).
9/// - Bare `KEY=value` with no quotes.
10///
11/// Inline comments are stripped on UNQUOTED values only. Sample seen in
12/// `.env` files: `DB_PASS=p4ssw0rd # rotate quarterly` -> value = `p4ssw0rd`.
13/// Quoted values keep `#` because the user has explicitly opted into the
14/// literal string including the hash.
15pub fn parse_env(text: &str) -> Vec<ExtractedPair> {
16    let mut pairs = Vec::new();
17    for (line_idx, line) in text.lines().enumerate() {
18        let trimmed = line.trim();
19        if trimmed.is_empty() || trimmed.starts_with('#') {
20            continue;
21        }
22        let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
23        if let Some((key, value)) = after_export.split_once('=') {
24            let key = key.trim();
25            let value = value.trim();
26            if key.is_empty() {
27                continue;
28            }
29            let unquoted = unquote_env_value(value);
30            pairs.push(ExtractedPair {
31                context: key.to_string(),
32                value: unquoted,
33                line: line_idx + 1,
34            });
35        }
36    }
37    pairs
38}
39
40/// Strip surrounding ASCII quotes (`"`, `'`, or `` ` ``) when both ends
41/// match; otherwise drop any trailing inline `# comment ...` segment and
42/// return the trimmed remainder.
43fn unquote_env_value(s: &str) -> String {
44    if s.len() >= 2 {
45        let first = s.as_bytes()[0];
46        let last = s.as_bytes()[s.len() - 1];
47        if matches!(first, b'"' | b'\'' | b'`') && first == last {
48            return s[1..s.len() - 1].to_string();
49        }
50    }
51    if let Some(hash_idx) = find_inline_comment(s) {
52        return s[..hash_idx].trim_end().to_string();
53    }
54    s.to_string()
55}
56
57/// Return the byte offset of an inline `# comment` start, when the `#`
58/// is preceded by ASCII whitespace. `None` if no such position exists.
59fn find_inline_comment(s: &str) -> Option<usize> {
60    let bytes = s.as_bytes();
61    bytes
62        .windows(2)
63        .position(|w| w[0].is_ascii_whitespace() && w[1] == b'#')
64        .map(|i| i + 1)
65}