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