1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use crate::parser_warn as warn;
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 fs::read_to_string(path).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 .filter_map(Value::as_mapping)
129 .filter_map(parse_chart_yaml_dependency)
130 .collect()
131}
132
133fn parse_chart_yaml_dependency(mapping: &Mapping) -> Option<Dependency> {
134 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
135 let version = mapping_get(mapping, "version").and_then(yaml_value_to_string);
136 let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
137 let condition = mapping_get(mapping, "condition").and_then(yaml_value_to_string);
138 let alias = mapping_get(mapping, "alias").and_then(yaml_value_to_string);
139 let tags = mapping_get(mapping, "tags")
140 .map(extract_string_values)
141 .unwrap_or_default();
142 let import_values = mapping_get(mapping, "import-values").and_then(yaml_to_json);
143
144 let mut extra_data = HashMap::new();
145 if let Some(repository) = repository {
146 extra_data.insert("repository".to_string(), JsonValue::String(repository));
147 }
148 if let Some(condition) = condition.clone() {
149 extra_data.insert("condition".to_string(), JsonValue::String(condition));
150 }
151 if let Some(alias) = alias {
152 extra_data.insert("alias".to_string(), JsonValue::String(alias));
153 }
154 if !tags.is_empty() {
155 extra_data.insert(
156 "tags".to_string(),
157 JsonValue::Array(tags.into_iter().map(JsonValue::String).collect()),
158 );
159 }
160 if let Some(import_values) = import_values {
161 extra_data.insert("import_values".to_string(), import_values);
162 }
163
164 Some(Dependency {
165 purl: build_helm_purl(
166 &name,
167 version
168 .as_deref()
169 .filter(|value| is_exact_chart_version(value)),
170 ),
171 extracted_requirement: version.clone(),
172 scope: Some("dependencies".to_string()),
173 is_runtime: Some(true),
174 is_optional: Some(condition.is_some() || extra_data.contains_key("tags")),
175 is_pinned: Some(version.as_deref().is_some_and(is_exact_chart_version)),
176 is_direct: Some(true),
177 resolved_package: None,
178 extra_data: (!extra_data.is_empty()).then_some(extra_data),
179 })
180}
181
182fn extract_chart_lock_dependencies(yaml_content: &Value) -> Vec<Dependency> {
183 let Some(entries) = yaml_content
184 .get("dependencies")
185 .and_then(Value::as_sequence)
186 else {
187 return Vec::new();
188 };
189
190 entries
191 .iter()
192 .filter_map(Value::as_mapping)
193 .filter_map(parse_chart_lock_dependency)
194 .collect()
195}
196
197fn parse_chart_lock_dependency(mapping: &Mapping) -> Option<Dependency> {
198 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
199 let version = mapping_get(mapping, "version").and_then(yaml_value_to_string)?;
200 let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
201
202 let mut extra_data = HashMap::new();
203 if let Some(repository) = repository {
204 extra_data.insert("repository".to_string(), JsonValue::String(repository));
205 }
206
207 Some(Dependency {
208 purl: build_helm_purl(&name, Some(&version)),
209 extracted_requirement: Some(version),
210 scope: Some("dependencies".to_string()),
211 is_runtime: Some(true),
212 is_optional: Some(false),
213 is_pinned: Some(true),
214 is_direct: Some(true),
215 resolved_package: None,
216 extra_data: (!extra_data.is_empty()).then_some(extra_data),
217 })
218}
219
220fn build_chart_yaml_extra_data(yaml_content: &Value) -> Option<HashMap<String, JsonValue>> {
221 let mut extra_data = HashMap::new();
222
223 for (field, key) in [
224 ("apiVersion", "api_version"),
225 ("appVersion", "app_version"),
226 ("kubeVersion", "kube_version"),
227 ("type", "chart_type"),
228 ("icon", "icon"),
229 ] {
230 if let Some(value) = extract_string_field(yaml_content, field) {
231 extra_data.insert(key.to_string(), JsonValue::String(value));
232 }
233 }
234
235 if let Some(value) = yaml_content.get("sources").and_then(yaml_to_json) {
236 extra_data.insert("sources".to_string(), value);
237 }
238 if let Some(value) = yaml_content.get("annotations").and_then(yaml_to_json) {
239 extra_data.insert("annotations".to_string(), value);
240 }
241
242 (!extra_data.is_empty()).then_some(extra_data)
243}
244
245fn extract_maintainers(yaml_content: &Value) -> Vec<Party> {
246 let Some(maintainers) = yaml_content.get("maintainers").and_then(Value::as_sequence) else {
247 return Vec::new();
248 };
249
250 maintainers
251 .iter()
252 .filter_map(Value::as_mapping)
253 .filter_map(|mapping| {
254 let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
255 let email = mapping_get(mapping, "email").and_then(yaml_value_to_string);
256 let url = mapping_get(mapping, "url").and_then(yaml_value_to_string);
257 Some(Party {
258 r#type: Some("person".to_string()),
259 role: Some("maintainer".to_string()),
260 name: Some(name),
261 email,
262 url,
263 organization: None,
264 organization_url: None,
265 timezone: None,
266 })
267 })
268 .collect()
269}
270
271fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
272 yaml_content.get(field).and_then(yaml_value_to_string)
273}
274
275fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
276 yaml_content
277 .get(field)
278 .map(extract_string_values)
279 .unwrap_or_default()
280}
281
282fn extract_string_values(value: &Value) -> Vec<String> {
283 match value {
284 Value::String(value) => vec![value.clone()],
285 Value::Sequence(values) => values.iter().filter_map(yaml_value_to_string).collect(),
286 _ => Vec::new(),
287 }
288}
289
290fn yaml_value_to_string(value: &Value) -> Option<String> {
291 match value {
292 Value::String(value) => Some(value.clone()),
293 Value::Number(value) => Some(value.to_string()),
294 Value::Bool(value) => Some(value.to_string()),
295 _ => None,
296 }
297}
298
299fn yaml_to_json(value: &Value) -> Option<JsonValue> {
300 serde_json::to_value(value).ok()
301}
302
303fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
304 mapping.get(Value::String(key.to_string()))
305}
306
307fn is_exact_chart_version(version: &str) -> bool {
308 let trimmed = version.trim();
309 if trimmed.is_empty()
310 || trimmed.contains('*')
311 || trimmed.contains('^')
312 || trimmed.contains('~')
313 || trimmed.contains('>')
314 || trimmed.contains('<')
315 || trimmed.contains('=')
316 || trimmed.contains('|')
317 || trimmed.contains(',')
318 || trimmed.contains(' ')
319 {
320 return false;
321 }
322
323 let core = trimmed
324 .split_once(['-', '+'])
325 .map(|(core, _)| core)
326 .unwrap_or(trimmed);
327
328 !core
329 .split('.')
330 .any(|segment| matches!(segment, "x" | "X" | "*"))
331}
332
333fn build_helm_purl(name: &str, version: Option<&str>) -> Option<String> {
334 let mut purl = PackageUrl::new(PackageType::Helm.as_str(), name).ok()?;
335 if let Some(version) = version {
336 purl.with_version(version).ok()?;
337 }
338 Some(purl.to_string())
339}
340
341fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
342 PackageData {
343 package_type: Some(PackageType::Helm),
344 datasource_id,
345 ..Default::default()
346 }
347}
348
349crate::register_parser!(
350 "Helm chart metadata",
351 &["**/Chart.yaml", "**/Chart.lock"],
352 "helm",
353 "YAML",
354 Some("https://helm.sh/docs/topics/charts/"),
355);