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