syncable_cli/analyzer/helmlint/parser/
values.rs1use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use serde_yaml::Value;
9
10#[derive(Debug, Clone)]
12pub struct ValuesFile {
13 pub values: Value,
15 pub line_map: HashMap<String, u32>,
17 pub defined_paths: HashSet<String>,
19}
20
21impl ValuesFile {
22 pub fn empty() -> Self {
24 Self {
25 values: Value::Mapping(serde_yaml::Mapping::new()),
26 line_map: HashMap::new(),
27 defined_paths: HashSet::new(),
28 }
29 }
30
31 pub fn get(&self, path: &str) -> Option<&Value> {
33 let parts: Vec<&str> = path.split('.').collect();
34 let mut current = &self.values;
35
36 for part in parts {
37 match current {
38 Value::Mapping(map) => {
39 current = map.get(Value::String(part.to_string()))?;
40 }
41 _ => return None,
42 }
43 }
44
45 Some(current)
46 }
47
48 pub fn has_path(&self, path: &str) -> bool {
50 self.defined_paths.contains(path)
51 }
52
53 pub fn line_for_path(&self, path: &str) -> Option<u32> {
55 self.line_map.get(path).copied()
56 }
57
58 pub fn paths_with_prefix(&self, prefix: &str) -> Vec<&str> {
60 self.defined_paths
61 .iter()
62 .filter(|p| p.starts_with(prefix))
63 .map(|s| s.as_str())
64 .collect()
65 }
66
67 pub fn is_sensitive_path(path: &str) -> bool {
69 let lower = path.to_lowercase();
70 lower.contains("password")
71 || lower.contains("secret")
72 || lower.contains("token")
73 || lower.contains("key")
74 || lower.contains("credential")
75 || lower.contains("apikey")
76 || lower.contains("api_key")
77 || lower.ends_with(".auth")
78 }
79
80 pub fn sensitive_paths(&self) -> Vec<&str> {
82 self.defined_paths
83 .iter()
84 .filter(|p| Self::is_sensitive_path(p))
85 .map(|s| s.as_str())
86 .collect()
87 }
88}
89
90#[derive(Debug)]
92pub struct ValuesParseError {
93 pub message: String,
94 pub line: Option<u32>,
95}
96
97impl std::fmt::Display for ValuesParseError {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 if let Some(line) = self.line {
100 write!(f, "line {}: {}", line, self.message)
101 } else {
102 write!(f, "{}", self.message)
103 }
104 }
105}
106
107impl std::error::Error for ValuesParseError {}
108
109pub fn parse_values_yaml(content: &str) -> Result<ValuesFile, ValuesParseError> {
111 let values: Value = serde_yaml::from_str(content).map_err(|e| {
113 let line = e.location().map(|l| l.line() as u32);
114 ValuesParseError {
115 message: e.to_string(),
116 line,
117 }
118 })?;
119
120 let (line_map, defined_paths) = build_line_map(content);
122
123 Ok(ValuesFile {
124 values,
125 line_map,
126 defined_paths,
127 })
128}
129
130pub fn parse_values_yaml_file(path: &Path) -> Result<ValuesFile, ValuesParseError> {
132 let content = std::fs::read_to_string(path).map_err(|e| ValuesParseError {
133 message: format!("Failed to read file: {}", e),
134 line: None,
135 })?;
136 parse_values_yaml(&content)
137}
138
139fn build_line_map(content: &str) -> (HashMap<String, u32>, HashSet<String>) {
141 let mut line_map = HashMap::new();
142 let mut defined_paths = HashSet::new();
143 let mut path_stack: Vec<(String, usize)> = Vec::new();
144
145 for (line_num, line) in content.lines().enumerate() {
146 let line_number = (line_num + 1) as u32;
147 let trimmed = line.trim();
148
149 if trimmed.is_empty() || trimmed.starts_with('#') {
151 continue;
152 }
153
154 let indent = line.len() - line.trim_start().len();
156
157 while let Some((_, stack_indent)) = path_stack.last() {
159 if indent <= *stack_indent {
160 path_stack.pop();
161 } else {
162 break;
163 }
164 }
165
166 if let Some(colon_pos) = trimmed.find(':') {
168 let key = trimmed[..colon_pos].trim();
169
170 if key.contains(' ') && !key.starts_with('"') && !key.starts_with('\'') {
172 continue;
173 }
174
175 let key = key.trim_matches('"').trim_matches('\'');
177
178 let full_path = if path_stack.is_empty() {
180 key.to_string()
181 } else {
182 let parent_path = &path_stack.last().unwrap().0;
183 format!("{}.{}", parent_path, key)
184 };
185
186 line_map.insert(full_path.clone(), line_number);
187 defined_paths.insert(full_path.clone());
188
189 let after_colon = trimmed[colon_pos + 1..].trim();
191 if after_colon.is_empty() || after_colon.starts_with('#') {
192 path_stack.push((full_path, indent));
194 }
195 }
196 }
197
198 (line_map, defined_paths)
199}
200
201pub fn extract_values_path(expr: &str) -> Option<&str> {
204 let trimmed = expr.trim();
205 trimmed.strip_prefix(".Values.")
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_parse_simple_values() {
214 let yaml = r#"
215replicaCount: 1
216image:
217 repository: nginx
218 tag: "1.25"
219"#;
220 let values = parse_values_yaml(yaml).unwrap();
221 assert!(values.has_path("replicaCount"));
222 assert!(values.has_path("image"));
223 assert!(values.has_path("image.repository"));
224 assert!(values.has_path("image.tag"));
225 }
226
227 #[test]
228 fn test_get_value() {
229 let yaml = r#"
230image:
231 repository: nginx
232 tag: "1.25"
233service:
234 port: 80
235"#;
236 let values = parse_values_yaml(yaml).unwrap();
237
238 assert_eq!(
239 values.get("image.repository"),
240 Some(&Value::String("nginx".to_string()))
241 );
242 assert_eq!(values.get("service.port"), Some(&Value::Number(80.into())));
243 assert_eq!(values.get("nonexistent"), None);
244 }
245
246 #[test]
247 fn test_line_numbers() {
248 let yaml = r#"replicaCount: 1
249image:
250 repository: nginx
251 tag: "1.25"
252"#;
253 let values = parse_values_yaml(yaml).unwrap();
254 assert_eq!(values.line_for_path("replicaCount"), Some(1));
255 assert_eq!(values.line_for_path("image"), Some(2));
256 assert_eq!(values.line_for_path("image.repository"), Some(3));
257 assert_eq!(values.line_for_path("image.tag"), Some(4));
258 }
259
260 #[test]
261 fn test_sensitive_paths() {
262 let yaml = r#"
263database:
264 password: secret123
265 host: localhost
266auth:
267 apiKey: abc123
268 token: xyz789
269"#;
270 let values = parse_values_yaml(yaml).unwrap();
271 let sensitive = values.sensitive_paths();
272
273 assert!(sensitive.contains(&"database.password"));
274 assert!(sensitive.contains(&"auth.apiKey"));
275 assert!(sensitive.contains(&"auth.token"));
276 assert!(!sensitive.contains(&"database.host"));
277 }
278
279 #[test]
280 fn test_extract_values_path() {
281 assert_eq!(
282 extract_values_path(".Values.image.repository"),
283 Some("image.repository")
284 );
285 assert_eq!(
286 extract_values_path(".Values.replicaCount"),
287 Some("replicaCount")
288 );
289 assert_eq!(extract_values_path(".Release.Name"), None);
290 assert_eq!(extract_values_path("something.else"), None);
291 }
292
293 #[test]
294 fn test_paths_with_prefix() {
295 let yaml = r#"
296image:
297 repository: nginx
298 tag: "1.25"
299 pullPolicy: Always
300service:
301 port: 80
302"#;
303 let values = parse_values_yaml(yaml).unwrap();
304 let image_paths = values.paths_with_prefix("image.");
305
306 assert_eq!(image_paths.len(), 3);
307 assert!(image_paths.contains(&"image.repository"));
308 assert!(image_paths.contains(&"image.tag"));
309 assert!(image_paths.contains(&"image.pullPolicy"));
310 }
311
312 #[test]
313 fn test_empty_values() {
314 let values = ValuesFile::empty();
315 assert!(!values.has_path("anything"));
316 }
317
318 #[test]
319 fn test_parse_error() {
320 let yaml = "invalid: [yaml";
321 let result = parse_values_yaml(yaml);
322 assert!(result.is_err());
323 }
324}