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