Skip to main content

provenant/parsers/
helm.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use crate::parser_warn as warn;
6use packageurl::PackageUrl;
7use serde_json::Value as JsonValue;
8use yaml_serde::{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    yaml_serde::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 code_view_url = yaml_content
70        .get("sources")
71        .map(extract_string_values)
72        .unwrap_or_default()
73        .into_iter()
74        .find(|value| !value.trim().is_empty());
75    let keywords = extract_string_list_field(yaml_content, "keywords");
76    let parties = extract_maintainers(yaml_content);
77    let dependencies = extract_chart_yaml_dependencies(yaml_content);
78    let extra_data = build_chart_yaml_extra_data(yaml_content);
79
80    PackageData {
81        package_type: Some(PackageType::Helm),
82        name: name.clone(),
83        version: version.clone(),
84        primary_language: Some("YAML".to_string()),
85        description,
86        parties,
87        keywords,
88        homepage_url,
89        code_view_url,
90        is_private: false,
91        extra_data,
92        dependencies,
93        datasource_id: Some(DatasourceId::HelmChartYaml),
94        purl: name
95            .as_deref()
96            .and_then(|name| build_helm_purl(name, version.as_deref())),
97        ..default_package_data(Some(DatasourceId::HelmChartYaml))
98    }
99}
100
101fn parse_chart_lock(yaml_content: &Value) -> PackageData {
102    let dependencies = extract_chart_lock_dependencies(yaml_content);
103
104    let mut extra_data = HashMap::new();
105    if let Some(digest) = extract_string_field(yaml_content, "digest") {
106        extra_data.insert("digest".to_string(), JsonValue::String(digest));
107    }
108    if let Some(generated) = extract_string_field(yaml_content, "generated") {
109        extra_data.insert("generated".to_string(), JsonValue::String(generated));
110    }
111
112    let mut package_data = default_package_data(Some(DatasourceId::HelmChartLock));
113    package_data.dependencies = dependencies;
114    package_data.extra_data = (!extra_data.is_empty()).then_some(extra_data);
115    package_data
116}
117
118fn extract_chart_yaml_dependencies(yaml_content: &Value) -> Vec<Dependency> {
119    let Some(entries) = yaml_content
120        .get("dependencies")
121        .and_then(Value::as_sequence)
122    else {
123        return Vec::new();
124    };
125
126    entries
127        .iter()
128        .filter_map(Value::as_mapping)
129        .filter_map(parse_chart_yaml_dependency)
130        .collect()
131}
132
133fn parse_chart_yaml_dependency(mapping: &Mapping) -> Option<Dependency> {
134    let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
135    let version = mapping_get(mapping, "version").and_then(yaml_value_to_string);
136    let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
137    let condition = mapping_get(mapping, "condition").and_then(yaml_value_to_string);
138    let alias = mapping_get(mapping, "alias").and_then(yaml_value_to_string);
139    let tags = mapping_get(mapping, "tags")
140        .map(extract_string_values)
141        .unwrap_or_default();
142    let import_values = mapping_get(mapping, "import-values").and_then(yaml_to_json);
143
144    let mut extra_data = HashMap::new();
145    if let Some(repository) = repository {
146        extra_data.insert("repository".to_string(), JsonValue::String(repository));
147    }
148    if let Some(condition) = condition.clone() {
149        extra_data.insert("condition".to_string(), JsonValue::String(condition));
150    }
151    if let Some(alias) = alias {
152        extra_data.insert("alias".to_string(), JsonValue::String(alias));
153    }
154    if !tags.is_empty() {
155        extra_data.insert(
156            "tags".to_string(),
157            JsonValue::Array(tags.into_iter().map(JsonValue::String).collect()),
158        );
159    }
160    if let Some(import_values) = import_values {
161        extra_data.insert("import_values".to_string(), import_values);
162    }
163
164    Some(Dependency {
165        purl: build_helm_purl(
166            &name,
167            version
168                .as_deref()
169                .filter(|value| is_exact_chart_version(value)),
170        ),
171        extracted_requirement: version.clone(),
172        scope: Some("dependencies".to_string()),
173        is_runtime: Some(true),
174        is_optional: Some(condition.is_some() || extra_data.contains_key("tags")),
175        is_pinned: Some(version.as_deref().is_some_and(is_exact_chart_version)),
176        is_direct: Some(true),
177        resolved_package: None,
178        extra_data: (!extra_data.is_empty()).then_some(extra_data),
179    })
180}
181
182fn extract_chart_lock_dependencies(yaml_content: &Value) -> Vec<Dependency> {
183    let Some(entries) = yaml_content
184        .get("dependencies")
185        .and_then(Value::as_sequence)
186    else {
187        return Vec::new();
188    };
189
190    entries
191        .iter()
192        .filter_map(Value::as_mapping)
193        .filter_map(parse_chart_lock_dependency)
194        .collect()
195}
196
197fn parse_chart_lock_dependency(mapping: &Mapping) -> Option<Dependency> {
198    let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
199    let version = mapping_get(mapping, "version").and_then(yaml_value_to_string)?;
200    let repository = mapping_get(mapping, "repository").and_then(yaml_value_to_string);
201
202    let mut extra_data = HashMap::new();
203    if let Some(repository) = repository {
204        extra_data.insert("repository".to_string(), JsonValue::String(repository));
205    }
206
207    Some(Dependency {
208        purl: build_helm_purl(&name, Some(&version)),
209        extracted_requirement: Some(version),
210        scope: Some("dependencies".to_string()),
211        is_runtime: Some(true),
212        is_optional: Some(false),
213        is_pinned: Some(true),
214        is_direct: Some(true),
215        resolved_package: None,
216        extra_data: (!extra_data.is_empty()).then_some(extra_data),
217    })
218}
219
220fn build_chart_yaml_extra_data(yaml_content: &Value) -> Option<HashMap<String, JsonValue>> {
221    let mut extra_data = HashMap::new();
222
223    for (field, key) in [
224        ("apiVersion", "api_version"),
225        ("appVersion", "app_version"),
226        ("kubeVersion", "kube_version"),
227        ("type", "chart_type"),
228        ("icon", "icon"),
229    ] {
230        if let Some(value) = extract_string_field(yaml_content, field) {
231            extra_data.insert(key.to_string(), JsonValue::String(value));
232        }
233    }
234
235    if let Some(value) = yaml_content.get("sources").and_then(yaml_to_json) {
236        extra_data.insert("sources".to_string(), value);
237    }
238    if let Some(value) = yaml_content.get("annotations").and_then(yaml_to_json) {
239        extra_data.insert("annotations".to_string(), value);
240    }
241
242    (!extra_data.is_empty()).then_some(extra_data)
243}
244
245fn extract_maintainers(yaml_content: &Value) -> Vec<Party> {
246    let Some(maintainers) = yaml_content.get("maintainers").and_then(Value::as_sequence) else {
247        return Vec::new();
248    };
249
250    maintainers
251        .iter()
252        .filter_map(Value::as_mapping)
253        .filter_map(|mapping| {
254            let name = mapping_get(mapping, "name").and_then(yaml_value_to_string)?;
255            let email = mapping_get(mapping, "email").and_then(yaml_value_to_string);
256            let url = mapping_get(mapping, "url").and_then(yaml_value_to_string);
257            Some(Party {
258                r#type: Some("person".to_string()),
259                role: Some("maintainer".to_string()),
260                name: Some(name),
261                email,
262                url,
263                organization: None,
264                organization_url: None,
265                timezone: None,
266            })
267        })
268        .collect()
269}
270
271fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
272    yaml_content.get(field).and_then(yaml_value_to_string)
273}
274
275fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
276    yaml_content
277        .get(field)
278        .map(extract_string_values)
279        .unwrap_or_default()
280}
281
282fn extract_string_values(value: &Value) -> Vec<String> {
283    match value {
284        Value::String(value) => vec![value.clone()],
285        Value::Sequence(values) => values.iter().filter_map(yaml_value_to_string).collect(),
286        _ => Vec::new(),
287    }
288}
289
290fn yaml_value_to_string(value: &Value) -> Option<String> {
291    match value {
292        Value::String(value) => Some(value.clone()),
293        Value::Number(value) => Some(value.to_string()),
294        Value::Bool(value) => Some(value.to_string()),
295        _ => None,
296    }
297}
298
299fn yaml_to_json(value: &Value) -> Option<JsonValue> {
300    serde_json::to_value(value).ok()
301}
302
303fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
304    mapping.get(Value::String(key.to_string()))
305}
306
307fn is_exact_chart_version(version: &str) -> bool {
308    let trimmed = version.trim();
309    if trimmed.is_empty()
310        || trimmed.contains('*')
311        || trimmed.contains('^')
312        || trimmed.contains('~')
313        || trimmed.contains('>')
314        || trimmed.contains('<')
315        || trimmed.contains('=')
316        || trimmed.contains('|')
317        || trimmed.contains(',')
318        || trimmed.contains(' ')
319    {
320        return false;
321    }
322
323    let core = trimmed
324        .split_once(['-', '+'])
325        .map(|(core, _)| core)
326        .unwrap_or(trimmed);
327
328    !core
329        .split('.')
330        .any(|segment| matches!(segment, "x" | "X" | "*"))
331}
332
333fn build_helm_purl(name: &str, version: Option<&str>) -> Option<String> {
334    let mut purl = PackageUrl::new(PackageType::Helm.as_str(), name).ok()?;
335    if let Some(version) = version {
336        purl.with_version(version).ok()?;
337    }
338    Some(purl.to_string())
339}
340
341fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
342    PackageData {
343        package_type: Some(PackageType::Helm),
344        datasource_id,
345        ..Default::default()
346    }
347}
348
349crate::register_parser!(
350    "Helm chart metadata",
351    &["**/Chart.yaml", "**/Chart.lock"],
352    "helm",
353    "YAML",
354    Some("https://helm.sh/docs/topics/charts/"),
355);