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