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::{CappedIterExt, 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, PartyType};
14
15use super::PackageParser;
16use super::license_normalization::normalize_spdx_declared_license;
17use super::metadata::ParserMetadata;
18
19pub struct HelmChartYamlParser;
20
21impl PackageParser for HelmChartYamlParser {
22    const PACKAGE_TYPE: PackageType = PackageType::Helm;
23
24    fn metadata() -> Vec<ParserMetadata> {
25        vec![ParserMetadata {
26            description: "Helm chart metadata",
27            file_patterns: &["**/Chart.yaml", "**/Chart.lock"],
28            package_type: "helm",
29            primary_language: "YAML",
30            documentation_url: Some("https://helm.sh/docs/topics/charts/"),
31        }]
32    }
33
34    fn is_match(path: &Path) -> bool {
35        path.file_name().is_some_and(|name| name == "Chart.yaml")
36    }
37
38    fn extract_packages(path: &Path) -> Vec<PackageData> {
39        let yaml_content = match read_yaml_file(path) {
40            Ok(content) => content,
41            Err(error) => {
42                warn!("Failed to read Chart.yaml at {:?}: {}", path, error);
43                return vec![default_package_data(Some(DatasourceId::HelmChartYaml))];
44            }
45        };
46
47        let mut package = parse_chart_yaml(&yaml_content);
48
49        // Helm 2 charts (apiVersion v1) declare dependencies in a sibling requirements.yaml
50        // rather than inline in Chart.yaml. When such a chart carries no dependencies, merge
51        // them from a neighbouring requirements.yaml (same `dependencies:` schema). Gate this
52        // on apiVersion v1 so a Helm 3 (v2) chart that legitimately has zero inline
53        // dependencies but shares a directory with an unrelated requirements.yaml (e.g. a
54        // monorepo with an Ansible/Python project alongside the chart) does not inherit them.
55        if extract_string_field(&yaml_content, "apiVersion").as_deref() == Some("v1")
56            && package.dependencies.is_empty()
57            && let Some(requirements_path) =
58                path.parent().map(|parent| parent.join("requirements.yaml"))
59            && requirements_path.is_file()
60        {
61            match read_yaml_file(&requirements_path) {
62                Ok(requirements_content) => {
63                    package.dependencies = extract_chart_yaml_dependencies(&requirements_content);
64                }
65                Err(error) => {
66                    warn!(
67                        "Failed to read sibling requirements.yaml at {:?}: {}",
68                        requirements_path, error
69                    );
70                }
71            }
72        }
73
74        vec![package]
75    }
76}
77
78pub struct HelmChartLockParser;
79
80impl PackageParser for HelmChartLockParser {
81    const PACKAGE_TYPE: PackageType = PackageType::Helm;
82
83    fn is_match(path: &Path) -> bool {
84        path.file_name().is_some_and(|name| name == "Chart.lock")
85    }
86
87    fn extract_packages(path: &Path) -> Vec<PackageData> {
88        let yaml_content = match read_yaml_file(path) {
89            Ok(content) => content,
90            Err(error) => {
91                warn!("Failed to read Chart.lock at {:?}: {}", path, error);
92                return vec![default_package_data(Some(DatasourceId::HelmChartLock))];
93            }
94        };
95
96        vec![parse_chart_lock(&yaml_content)]
97    }
98}
99
100fn read_yaml_file(path: &Path) -> Result<Value, String> {
101    let content =
102        read_file_to_string(path, None).map_err(|error| format!("Failed to read file: {error}"))?;
103    yaml_serde::from_str(&content).map_err(|error| format!("Failed to parse YAML: {error}"))
104}
105
106fn parse_chart_yaml(yaml_content: &Value) -> PackageData {
107    let name = extract_string_field(yaml_content, "name");
108    let version = extract_string_field(yaml_content, "version");
109    let description = extract_string_field(yaml_content, "description");
110    let homepage_url = extract_string_field(yaml_content, "home");
111    let code_view_url = yaml_content
112        .get("sources")
113        .map(extract_string_values)
114        .unwrap_or_default()
115        .into_iter()
116        .find(|value| !value.trim().is_empty());
117    let keywords = extract_string_list_field(yaml_content, "keywords");
118    let parties = extract_maintainers(yaml_content);
119    let dependencies = extract_chart_yaml_dependencies(yaml_content);
120    let extra_data = build_chart_yaml_extra_data(yaml_content);
121
122    // Helm's Chart.yaml has no first-class license field; the conventional
123    // declared-license surface is the Artifact Hub `artifacthub.io/license`
124    // annotation, whose value is an SPDX expression. Normalize it through the shared
125    // declared-license path (SPDX expression, then alias fallback).
126    let extracted_license_statement = extract_chart_license_statement(yaml_content);
127    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
128        normalize_spdx_declared_license(extracted_license_statement.as_deref());
129
130    PackageData {
131        package_type: Some(PackageType::Helm),
132        name: name.clone(),
133        version: version.clone(),
134        primary_language: Some("YAML".to_string()),
135        description,
136        parties,
137        keywords,
138        homepage_url,
139        code_view_url,
140        extracted_license_statement,
141        declared_license_expression,
142        declared_license_expression_spdx,
143        license_detections,
144        is_private: false,
145        extra_data,
146        dependencies,
147        datasource_id: Some(DatasourceId::HelmChartYaml),
148        purl: name
149            .as_deref()
150            .and_then(|name| build_helm_purl(name, version.as_deref())),
151        ..default_package_data(Some(DatasourceId::HelmChartYaml))
152    }
153}
154
155/// Reads the Artifact Hub `artifacthub.io/license` annotation, the conventional
156/// declared-license surface for a Helm chart (an SPDX expression such as
157/// `Apache-2.0`). Helm's `Chart.yaml` spec has no dedicated license field.
158fn extract_chart_license_statement(yaml_content: &Value) -> Option<String> {
159    yaml_content
160        .get("annotations")
161        .and_then(|annotations| annotations.get("artifacthub.io/license"))
162        .and_then(yaml_value_to_string)
163        .filter(|value| !value.trim().is_empty())
164}
165
166fn parse_chart_lock(yaml_content: &Value) -> PackageData {
167    let dependencies = extract_chart_lock_dependencies(yaml_content);
168
169    let mut extra_data = HashMap::new();
170    if let Some(digest) = extract_string_field(yaml_content, "digest") {
171        extra_data.insert("digest".to_string(), JsonValue::String(digest));
172    }
173    if let Some(generated) = extract_string_field(yaml_content, "generated") {
174        extra_data.insert("generated".to_string(), JsonValue::String(generated));
175    }
176
177    let mut package_data = default_package_data(Some(DatasourceId::HelmChartLock));
178    package_data.dependencies = dependencies;
179    package_data.extra_data = (!extra_data.is_empty()).then_some(extra_data);
180    package_data
181}
182
183fn extract_chart_yaml_dependencies(yaml_content: &Value) -> Vec<Dependency> {
184    let Some(entries) = yaml_content
185        .get("dependencies")
186        .and_then(Value::as_sequence)
187    else {
188        return Vec::new();
189    };
190
191    entries
192        .iter()
193        .capped("Chart.yaml dependencies")
194        .filter_map(Value::as_mapping)
195        .filter_map(parse_chart_yaml_dependency)
196        .collect()
197}
198
199fn parse_chart_yaml_dependency(mapping: &Mapping) -> Option<Dependency> {
200    let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
201    let version = mapping_get(mapping, "version").and_then(yaml_value_to_string);
202    let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
203    let condition = mapping_get(mapping, "condition").and_then(yaml_value_to_string);
204    let alias = mapping_get(mapping, "alias").and_then(yaml_value_to_string);
205    let tags = mapping_get(mapping, "tags")
206        .map(extract_string_values)
207        .unwrap_or_default();
208    let import_values = mapping_get(mapping, "import-values").and_then(yaml_to_json);
209
210    let mut extra_data = HashMap::new();
211    if let Some(repository) = repository {
212        extra_data.insert("repository".to_string(), JsonValue::String(repository));
213    }
214    if let Some(condition) = condition.clone() {
215        extra_data.insert("condition".to_string(), JsonValue::String(condition));
216    }
217    if let Some(alias) = alias {
218        extra_data.insert("alias".to_string(), JsonValue::String(alias));
219    }
220    if !tags.is_empty() {
221        extra_data.insert(
222            "tags".to_string(),
223            JsonValue::Array(tags.into_iter().map(JsonValue::String).collect()),
224        );
225    }
226    if let Some(import_values) = import_values {
227        extra_data.insert("import_values".to_string(), import_values);
228    }
229
230    Some(Dependency {
231        purl: build_helm_purl(
232            &name,
233            version
234                .as_deref()
235                .filter(|value| is_exact_chart_version(value)),
236        ),
237        extracted_requirement: version.clone(),
238        scope: Some("dependencies".to_string()),
239        is_runtime: Some(true),
240        is_optional: Some(condition.is_some() || extra_data.contains_key("tags")),
241        is_pinned: Some(version.as_deref().is_some_and(is_exact_chart_version)),
242        is_direct: Some(true),
243        resolved_package: None,
244        extra_data: (!extra_data.is_empty()).then_some(extra_data),
245    })
246}
247
248fn extract_chart_lock_dependencies(yaml_content: &Value) -> Vec<Dependency> {
249    let Some(entries) = yaml_content
250        .get("dependencies")
251        .and_then(Value::as_sequence)
252    else {
253        return Vec::new();
254    };
255
256    entries
257        .iter()
258        .capped("Chart.lock dependencies")
259        .filter_map(Value::as_mapping)
260        .filter_map(parse_chart_lock_dependency)
261        .collect()
262}
263
264fn parse_chart_lock_dependency(mapping: &Mapping) -> Option<Dependency> {
265    let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
266    let version = mapping_get(mapping, "version").and_then(yaml_value_to_string)?;
267    let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
268
269    let mut extra_data = HashMap::new();
270    if let Some(repository) = repository {
271        extra_data.insert("repository".to_string(), JsonValue::String(repository));
272    }
273
274    Some(Dependency {
275        purl: build_helm_purl(&name, Some(&version)),
276        extracted_requirement: Some(version),
277        scope: Some("dependencies".to_string()),
278        is_runtime: Some(true),
279        is_optional: Some(false),
280        is_pinned: Some(true),
281        is_direct: Some(true),
282        resolved_package: None,
283        extra_data: (!extra_data.is_empty()).then_some(extra_data),
284    })
285}
286
287fn build_chart_yaml_extra_data(yaml_content: &Value) -> Option<HashMap<String, JsonValue>> {
288    let mut extra_data = HashMap::new();
289
290    for (field, key) in [
291        ("apiVersion", "api_version"),
292        ("appVersion", "app_version"),
293        ("kubeVersion", "kube_version"),
294        ("type", "chart_type"),
295        ("icon", "icon"),
296    ] {
297        if let Some(value) = extract_string_field(yaml_content, field) {
298            extra_data.insert(key.to_string(), JsonValue::String(value));
299        }
300    }
301
302    if let Some(value) = yaml_content.get("sources").and_then(yaml_to_json) {
303        extra_data.insert("sources".to_string(), value);
304    }
305    if let Some(value) = yaml_content.get("annotations").and_then(yaml_to_json) {
306        extra_data.insert("annotations".to_string(), value);
307    }
308
309    (!extra_data.is_empty()).then_some(extra_data)
310}
311
312fn extract_maintainers(yaml_content: &Value) -> Vec<Party> {
313    let Some(maintainers) = yaml_content.get("maintainers").and_then(Value::as_sequence) else {
314        return Vec::new();
315    };
316
317    maintainers
318        .iter()
319        .capped("Chart.yaml maintainers")
320        .filter_map(Value::as_mapping)
321        .filter_map(|mapping| {
322            let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
323            let email = mapping_get(mapping, "email").and_then(yaml_value_to_string);
324            let url = mapping_get(mapping, "url").and_then(yaml_value_to_string);
325            Some(Party {
326                r#type: Some(PartyType::Person),
327                role: Some("maintainer".to_string()),
328                name: Some(name),
329                email,
330                url,
331                organization: None,
332                organization_url: None,
333                timezone: None,
334            })
335        })
336        .collect()
337}
338
339fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
340    yaml_content.get(field).and_then(yaml_value_to_string)
341}
342
343fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
344    yaml_content
345        .get(field)
346        .map(extract_string_values)
347        .unwrap_or_default()
348}
349
350fn extract_string_values(value: &Value) -> Vec<String> {
351    match value {
352        Value::String(value) => vec![truncate_field(value.clone())],
353        Value::Sequence(values) => values
354            .iter()
355            .capped("Chart.yaml string list field")
356            .filter_map(yaml_value_to_string)
357            .collect(),
358        _ => Vec::new(),
359    }
360}
361
362fn yaml_value_to_string(value: &Value) -> Option<String> {
363    match value {
364        Value::String(value) => Some(truncate_field(value.clone())),
365        Value::Number(value) => Some(truncate_field(value.to_string())),
366        Value::Bool(value) => Some(truncate_field(value.to_string())),
367        _ => None,
368    }
369}
370
371fn yaml_to_json(value: &Value) -> Option<JsonValue> {
372    serde_json::to_value(value).ok()
373}
374
375fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
376    mapping.get(Value::String(key.to_string()))
377}
378
379fn is_exact_chart_version(version: &str) -> bool {
380    let trimmed = version.trim();
381    if trimmed.is_empty()
382        || trimmed.contains('*')
383        || trimmed.contains('^')
384        || trimmed.contains('~')
385        || trimmed.contains('>')
386        || trimmed.contains('<')
387        || trimmed.contains('=')
388        || trimmed.contains('|')
389        || trimmed.contains(',')
390        || trimmed.contains(' ')
391    {
392        return false;
393    }
394
395    let core = trimmed
396        .split_once(['-', '+'])
397        .map(|(core, _)| core)
398        .unwrap_or(trimmed);
399
400    !core
401        .split('.')
402        .any(|segment| matches!(segment, "x" | "X" | "*"))
403}
404
405fn build_helm_purl(name: &str, version: Option<&str>) -> Option<String> {
406    let mut purl = PackageUrl::new(PackageType::Helm.as_str(), name).ok()?;
407    if let Some(version) = version {
408        purl.with_version(version).ok()?;
409    }
410    Some(purl.to_string())
411}
412
413fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
414    PackageData {
415        package_type: Some(PackageType::Helm),
416        datasource_id,
417        ..Default::default()
418    }
419}