1use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
9use packageurl::PackageUrl;
10use serde_json::Value as JsonValue;
11use yaml_serde::{Mapping, Value};
12
13use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
14
15use super::PackageParser;
16use super::metadata::ParserMetadata;
17
18pub struct HelmChartYamlParser;
19
20impl PackageParser for HelmChartYamlParser {
21 const PACKAGE_TYPE: PackageType = PackageType::Helm;
22
23 fn metadata() -> Vec<ParserMetadata> {
24 vec![ParserMetadata {
25 description: "Helm chart metadata",
26 file_patterns: &["**/Chart.yaml", "**/Chart.lock"],
27 package_type: "helm",
28 primary_language: "YAML",
29 documentation_url: Some("https://helm.sh/docs/topics/charts/"),
30 }]
31 }
32
33 fn is_match(path: &Path) -> bool {
34 path.file_name().is_some_and(|name| name == "Chart.yaml")
35 }
36
37 fn extract_packages(path: &Path) -> Vec<PackageData> {
38 let yaml_content = match read_yaml_file(path) {
39 Ok(content) => content,
40 Err(error) => {
41 warn!("Failed to read Chart.yaml at {:?}: {}", path, error);
42 return vec![default_package_data(Some(DatasourceId::HelmChartYaml))];
43 }
44 };
45
46 let mut package = parse_chart_yaml(&yaml_content);
47
48 if extract_string_field(&yaml_content, "apiVersion").as_deref() == Some("v1")
55 && package.dependencies.is_empty()
56 && let Some(requirements_path) =
57 path.parent().map(|parent| parent.join("requirements.yaml"))
58 && requirements_path.is_file()
59 {
60 match read_yaml_file(&requirements_path) {
61 Ok(requirements_content) => {
62 package.dependencies = extract_chart_yaml_dependencies(&requirements_content);
63 }
64 Err(error) => {
65 warn!(
66 "Failed to read sibling requirements.yaml at {:?}: {}",
67 requirements_path, error
68 );
69 }
70 }
71 }
72
73 vec![package]
74 }
75}
76
77pub struct HelmChartLockParser;
78
79impl PackageParser for HelmChartLockParser {
80 const PACKAGE_TYPE: PackageType = PackageType::Helm;
81
82 fn is_match(path: &Path) -> bool {
83 path.file_name().is_some_and(|name| name == "Chart.lock")
84 }
85
86 fn extract_packages(path: &Path) -> Vec<PackageData> {
87 let yaml_content = match read_yaml_file(path) {
88 Ok(content) => content,
89 Err(error) => {
90 warn!("Failed to read Chart.lock at {:?}: {}", path, error);
91 return vec![default_package_data(Some(DatasourceId::HelmChartLock))];
92 }
93 };
94
95 vec![parse_chart_lock(&yaml_content)]
96 }
97}
98
99fn read_yaml_file(path: &Path) -> Result<Value, String> {
100 let content =
101 read_file_to_string(path, None).map_err(|error| format!("Failed to read file: {error}"))?;
102 yaml_serde::from_str(&content).map_err(|error| format!("Failed to parse YAML: {error}"))
103}
104
105fn parse_chart_yaml(yaml_content: &Value) -> PackageData {
106 let name = extract_string_field(yaml_content, "name");
107 let version = extract_string_field(yaml_content, "version");
108 let description = extract_string_field(yaml_content, "description");
109 let homepage_url = extract_string_field(yaml_content, "home");
110 let code_view_url = yaml_content
111 .get("sources")
112 .map(extract_string_values)
113 .unwrap_or_default()
114 .into_iter()
115 .find(|value| !value.trim().is_empty());
116 let keywords = extract_string_list_field(yaml_content, "keywords");
117 let parties = extract_maintainers(yaml_content);
118 let dependencies = extract_chart_yaml_dependencies(yaml_content);
119 let extra_data = build_chart_yaml_extra_data(yaml_content);
120
121 PackageData {
122 package_type: Some(PackageType::Helm),
123 name: name.clone(),
124 version: version.clone(),
125 primary_language: Some("YAML".to_string()),
126 description,
127 parties,
128 keywords,
129 homepage_url,
130 code_view_url,
131 is_private: false,
132 extra_data,
133 dependencies,
134 datasource_id: Some(DatasourceId::HelmChartYaml),
135 purl: name
136 .as_deref()
137 .and_then(|name| build_helm_purl(name, version.as_deref())),
138 ..default_package_data(Some(DatasourceId::HelmChartYaml))
139 }
140}
141
142fn parse_chart_lock(yaml_content: &Value) -> PackageData {
143 let dependencies = extract_chart_lock_dependencies(yaml_content);
144
145 let mut extra_data = HashMap::new();
146 if let Some(digest) = extract_string_field(yaml_content, "digest") {
147 extra_data.insert("digest".to_string(), JsonValue::String(digest));
148 }
149 if let Some(generated) = extract_string_field(yaml_content, "generated") {
150 extra_data.insert("generated".to_string(), JsonValue::String(generated));
151 }
152
153 let mut package_data = default_package_data(Some(DatasourceId::HelmChartLock));
154 package_data.dependencies = dependencies;
155 package_data.extra_data = (!extra_data.is_empty()).then_some(extra_data);
156 package_data
157}
158
159fn extract_chart_yaml_dependencies(yaml_content: &Value) -> Vec<Dependency> {
160 let Some(entries) = yaml_content
161 .get("dependencies")
162 .and_then(Value::as_sequence)
163 else {
164 return Vec::new();
165 };
166
167 entries
168 .iter()
169 .capped("Chart.yaml dependencies")
170 .filter_map(Value::as_mapping)
171 .filter_map(parse_chart_yaml_dependency)
172 .collect()
173}
174
175fn parse_chart_yaml_dependency(mapping: &Mapping) -> Option<Dependency> {
176 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
177 let version = mapping_get(mapping, "version").and_then(yaml_value_to_string);
178 let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
179 let condition = mapping_get(mapping, "condition").and_then(yaml_value_to_string);
180 let alias = mapping_get(mapping, "alias").and_then(yaml_value_to_string);
181 let tags = mapping_get(mapping, "tags")
182 .map(extract_string_values)
183 .unwrap_or_default();
184 let import_values = mapping_get(mapping, "import-values").and_then(yaml_to_json);
185
186 let mut extra_data = HashMap::new();
187 if let Some(repository) = repository {
188 extra_data.insert("repository".to_string(), JsonValue::String(repository));
189 }
190 if let Some(condition) = condition.clone() {
191 extra_data.insert("condition".to_string(), JsonValue::String(condition));
192 }
193 if let Some(alias) = alias {
194 extra_data.insert("alias".to_string(), JsonValue::String(alias));
195 }
196 if !tags.is_empty() {
197 extra_data.insert(
198 "tags".to_string(),
199 JsonValue::Array(tags.into_iter().map(JsonValue::String).collect()),
200 );
201 }
202 if let Some(import_values) = import_values {
203 extra_data.insert("import_values".to_string(), import_values);
204 }
205
206 Some(Dependency {
207 purl: build_helm_purl(
208 &name,
209 version
210 .as_deref()
211 .filter(|value| is_exact_chart_version(value)),
212 ),
213 extracted_requirement: version.clone(),
214 scope: Some("dependencies".to_string()),
215 is_runtime: Some(true),
216 is_optional: Some(condition.is_some() || extra_data.contains_key("tags")),
217 is_pinned: Some(version.as_deref().is_some_and(is_exact_chart_version)),
218 is_direct: Some(true),
219 resolved_package: None,
220 extra_data: (!extra_data.is_empty()).then_some(extra_data),
221 })
222}
223
224fn extract_chart_lock_dependencies(yaml_content: &Value) -> Vec<Dependency> {
225 let Some(entries) = yaml_content
226 .get("dependencies")
227 .and_then(Value::as_sequence)
228 else {
229 return Vec::new();
230 };
231
232 entries
233 .iter()
234 .capped("Chart.lock dependencies")
235 .filter_map(Value::as_mapping)
236 .filter_map(parse_chart_lock_dependency)
237 .collect()
238}
239
240fn parse_chart_lock_dependency(mapping: &Mapping) -> Option<Dependency> {
241 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
242 let version = mapping_get(mapping, "version").and_then(yaml_value_to_string)?;
243 let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
244
245 let mut extra_data = HashMap::new();
246 if let Some(repository) = repository {
247 extra_data.insert("repository".to_string(), JsonValue::String(repository));
248 }
249
250 Some(Dependency {
251 purl: build_helm_purl(&name, Some(&version)),
252 extracted_requirement: Some(version),
253 scope: Some("dependencies".to_string()),
254 is_runtime: Some(true),
255 is_optional: Some(false),
256 is_pinned: Some(true),
257 is_direct: Some(true),
258 resolved_package: None,
259 extra_data: (!extra_data.is_empty()).then_some(extra_data),
260 })
261}
262
263fn build_chart_yaml_extra_data(yaml_content: &Value) -> Option<HashMap<String, JsonValue>> {
264 let mut extra_data = HashMap::new();
265
266 for (field, key) in [
267 ("apiVersion", "api_version"),
268 ("appVersion", "app_version"),
269 ("kubeVersion", "kube_version"),
270 ("type", "chart_type"),
271 ("icon", "icon"),
272 ] {
273 if let Some(value) = extract_string_field(yaml_content, field) {
274 extra_data.insert(key.to_string(), JsonValue::String(value));
275 }
276 }
277
278 if let Some(value) = yaml_content.get("sources").and_then(yaml_to_json) {
279 extra_data.insert("sources".to_string(), value);
280 }
281 if let Some(value) = yaml_content.get("annotations").and_then(yaml_to_json) {
282 extra_data.insert("annotations".to_string(), value);
283 }
284
285 (!extra_data.is_empty()).then_some(extra_data)
286}
287
288fn extract_maintainers(yaml_content: &Value) -> Vec<Party> {
289 let Some(maintainers) = yaml_content.get("maintainers").and_then(Value::as_sequence) else {
290 return Vec::new();
291 };
292
293 maintainers
294 .iter()
295 .capped("Chart.yaml maintainers")
296 .filter_map(Value::as_mapping)
297 .filter_map(|mapping| {
298 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
299 let email = mapping_get(mapping, "email").and_then(yaml_value_to_string);
300 let url = mapping_get(mapping, "url").and_then(yaml_value_to_string);
301 Some(Party {
302 r#type: Some(PartyType::Person),
303 role: Some("maintainer".to_string()),
304 name: Some(name),
305 email,
306 url,
307 organization: None,
308 organization_url: None,
309 timezone: None,
310 })
311 })
312 .collect()
313}
314
315fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
316 yaml_content.get(field).and_then(yaml_value_to_string)
317}
318
319fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
320 yaml_content
321 .get(field)
322 .map(extract_string_values)
323 .unwrap_or_default()
324}
325
326fn extract_string_values(value: &Value) -> Vec<String> {
327 match value {
328 Value::String(value) => vec![truncate_field(value.clone())],
329 Value::Sequence(values) => values
330 .iter()
331 .capped("Chart.yaml string list field")
332 .filter_map(yaml_value_to_string)
333 .collect(),
334 _ => Vec::new(),
335 }
336}
337
338fn yaml_value_to_string(value: &Value) -> Option<String> {
339 match value {
340 Value::String(value) => Some(truncate_field(value.clone())),
341 Value::Number(value) => Some(truncate_field(value.to_string())),
342 Value::Bool(value) => Some(truncate_field(value.to_string())),
343 _ => None,
344 }
345}
346
347fn yaml_to_json(value: &Value) -> Option<JsonValue> {
348 serde_json::to_value(value).ok()
349}
350
351fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
352 mapping.get(Value::String(key.to_string()))
353}
354
355fn is_exact_chart_version(version: &str) -> bool {
356 let trimmed = version.trim();
357 if trimmed.is_empty()
358 || trimmed.contains('*')
359 || trimmed.contains('^')
360 || trimmed.contains('~')
361 || trimmed.contains('>')
362 || trimmed.contains('<')
363 || trimmed.contains('=')
364 || trimmed.contains('|')
365 || trimmed.contains(',')
366 || trimmed.contains(' ')
367 {
368 return false;
369 }
370
371 let core = trimmed
372 .split_once(['-', '+'])
373 .map(|(core, _)| core)
374 .unwrap_or(trimmed);
375
376 !core
377 .split('.')
378 .any(|segment| matches!(segment, "x" | "X" | "*"))
379}
380
381fn build_helm_purl(name: &str, version: Option<&str>) -> Option<String> {
382 let mut purl = PackageUrl::new(PackageType::Helm.as_str(), name).ok()?;
383 if let Some(version) = version {
384 purl.with_version(version).ok()?;
385 }
386 Some(purl.to_string())
387}
388
389fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
390 PackageData {
391 package_type: Some(PackageType::Helm),
392 datasource_id,
393 ..Default::default()
394 }
395}