Skip to main content

keyhog_scanner/structured/parsers/
hcl.rs

1use super::ExtractedPair;
2
3/// Parse Terraform / HCL `variable "<name>" { default = "<value>" }`
4/// blocks, flat `.tfvars` assignments, and simple `locals { x = "v" }`
5/// assignment shapes into `(context, value)` pairs.
6pub fn parse_hcl(text: &str) -> Vec<ExtractedPair> {
7    let mut pairs = Vec::new();
8    let lines: Vec<&str> = text.lines().collect();
9    let mut index = 0;
10    while index < lines.len() {
11        let line = lines[index];
12        let trimmed = line.trim_start();
13        if let Some((var_name, _start_line)) = parse_variable_header(trimmed) {
14            let mut depth = 1usize;
15            let mut consumed = 1usize;
16            for offset in 1..MAX_VARIABLE_BLOCK_LINES {
17                if index + offset >= lines.len() {
18                    break;
19                }
20                let inner = lines[index + offset];
21                let body = inner.trim();
22                if body.contains('{') {
23                    depth += body.matches('{').count();
24                }
25                if body.contains('}') {
26                    depth = depth.saturating_sub(body.matches('}').count());
27                    if depth == 0 {
28                        consumed = offset + 1;
29                        break;
30                    }
31                }
32                if let Some(value) = parse_hcl_default(body) {
33                    if !value.is_empty() {
34                        pairs.push(ExtractedPair {
35                            context: var_name.clone(),
36                            value,
37                            line: index + offset + 1,
38                        });
39                    }
40                }
41            }
42            index += consumed;
43            continue;
44        }
45        if let Some((name, value)) = parse_hcl_assignment(trimmed) {
46            if !name.is_empty() && !value.is_empty() {
47                pairs.push(ExtractedPair {
48                    context: name,
49                    value,
50                    line: index + 1,
51                });
52            }
53        }
54        index += 1;
55    }
56    pairs
57}
58
59/// Real terraform blocks are short; cap the lookahead so malformed files do not
60/// run into the next block indefinitely.
61const MAX_VARIABLE_BLOCK_LINES: usize = 16;
62
63fn parse_variable_header(line: &str) -> Option<(String, usize)> {
64    let rest = line.strip_prefix("variable")?;
65    if !rest.starts_with(|c: char| c.is_ascii_whitespace()) {
66        return None;
67    }
68    let rest = rest.trim_start();
69    let rest = rest.strip_prefix('"')?;
70    let end = rest.find('"')?;
71    let name = &rest[..end];
72    if name.is_empty() {
73        return None;
74    }
75    Some((name.to_string(), 0))
76}
77
78fn parse_hcl_default(line: &str) -> Option<String> {
79    let trimmed = line.trim_start();
80    let rest = trimmed.strip_prefix("default")?;
81    let rest = rest.trim_start();
82    let rest = rest.strip_prefix('=')?.trim_start();
83    extract_quoted_value(rest)
84}
85
86fn parse_hcl_assignment(line: &str) -> Option<(String, String)> {
87    if line.starts_with('#') || line.starts_with("//") || line.ends_with('{') || !line.contains('=')
88    {
89        return None;
90    }
91    for kw in [
92        "variable",
93        "locals",
94        "resource",
95        "module",
96        "provider",
97        "data",
98        "output",
99        "terraform",
100    ] {
101        if line.starts_with(kw)
102            && line[kw.len()..]
103                .chars()
104                .next()
105                .is_some_and(|c| c.is_ascii_whitespace() || c == '{')
106        {
107            return None;
108        }
109    }
110    let (name_part, value_part) = line.split_once('=')?;
111    let name = name_part.trim();
112    if name.is_empty()
113        || !name
114            .chars()
115            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
116    {
117        return None;
118    }
119    let value = extract_quoted_value(value_part.trim_start())?;
120    Some((name.to_string(), value))
121}
122
123fn extract_quoted_value(s: &str) -> Option<String> {
124    let bytes = s.as_bytes();
125    if bytes.is_empty() {
126        return None;
127    }
128    let quote = bytes[0];
129    if !matches!(quote, b'"' | b'\'' | b'`') {
130        return None;
131    }
132    let body = &s[1..];
133    let end = body.find(quote as char)?;
134    Some(body[..end].to_string())
135}