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;
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}