Skip to main content

provenant/parsers/
helm.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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);