Skip to main content

provenant/parsers/
helm.rs

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