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