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