Skip to main content

provenant/parsers/
conan_data.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Conan conandata.yml files.
8//!
9//! Extracts package metadata from `conandata.yml` files which contain
10//! external source information for Conan packages.
11//!
12//! # Supported Formats
13//! - `conandata.yml` - Conan external source metadata
14//!
15//! # Key Features
16//! - Version-specific source URLs
17//! - SHA256 checksums
18//! - Multiple source mirrors support
19//! - Patch metadata extraction (beyond Python which ignores patches)
20//!
21//! # Implementation Notes
22//! - Format: YAML with `sources` dict containing version→{url, sha256}
23//! - Each version can have multiple URLs (list or single string)
24//! - Patches section contains version→[{patch_file, patch_description, patch_type}]
25//! - Spec: https://docs.conan.io/2/tutorial/creating_packages/handle_sources_in_packages.html
26
27use crate::models::{DatasourceId, PackageType, Sha256Digest};
28use std::collections::HashMap;
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use serde::{Deserialize, Serialize};
33use serde_json::json;
34
35use crate::models::PackageData;
36use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
37
38use super::PackageParser;
39
40const PACKAGE_TYPE: PackageType = PackageType::Conan;
41
42fn default_package_data() -> PackageData {
43    PackageData {
44        package_type: Some(PACKAGE_TYPE),
45        primary_language: Some("C++".to_string()),
46        datasource_id: Some(DatasourceId::ConanConanDataYml),
47        ..Default::default()
48    }
49}
50
51/// Parser for Conan conandata.yml files
52pub struct ConanDataParser;
53
54#[derive(Debug, Deserialize, Serialize)]
55struct ConanDataYml {
56    sources: Option<HashMap<String, SourcesValue>>,
57    patches: Option<HashMap<String, PatchesValue>>,
58}
59
60#[derive(Debug, Deserialize, Serialize)]
61#[serde(untagged)]
62enum SourcesValue {
63    Single(SourceInfo),
64    Multiple(Vec<SourceInfo>),
65}
66
67#[derive(Debug, Deserialize, Serialize)]
68#[serde(untagged)]
69enum UrlValue {
70    Single(String),
71    Multiple(Vec<String>),
72}
73
74#[derive(Debug, Deserialize, Serialize)]
75#[serde(untagged)]
76enum PatchesValue {
77    List(Vec<PatchInfo>),
78    String(String),
79}
80
81#[derive(Debug, Deserialize, Serialize)]
82struct PatchInfo {
83    patch_file: Option<String>,
84    patch_description: Option<String>,
85    patch_type: Option<String>,
86}
87
88#[derive(Debug, Deserialize, Serialize)]
89struct SourceInfo {
90    url: Option<UrlValue>,
91    sha256: Option<String>,
92}
93
94impl SourceInfo {
95    fn primary_download_url(&self) -> Option<String> {
96        match &self.url {
97            Some(UrlValue::Single(url)) => Some(truncate_field(url.clone())),
98            Some(UrlValue::Multiple(urls)) if !urls.is_empty() => {
99                Some(truncate_field(urls[0].clone()))
100            }
101            _ => None,
102        }
103    }
104
105    fn additional_data_json(&self) -> serde_json::Value {
106        let mut entry = serde_json::Map::new();
107
108        if let Some(url) = &self.url {
109            match url {
110                UrlValue::Single(value) => {
111                    entry.insert("url".to_string(), json!(truncate_field(value.clone())));
112                }
113                UrlValue::Multiple(values) => {
114                    let urls: Vec<_> = values.iter().cloned().map(truncate_field).collect();
115                    entry.insert("url".to_string(), json!(urls));
116                }
117            }
118        }
119
120        if let Some(sha256) = &self.sha256 {
121            entry.insert("sha256".to_string(), json!(sha256));
122        }
123
124        serde_json::Value::Object(entry)
125    }
126}
127
128fn sources_to_infos(sources_value: SourcesValue) -> Vec<SourceInfo> {
129    match sources_value {
130        SourcesValue::Single(source) => vec![source],
131        SourcesValue::Multiple(sources) => sources,
132    }
133}
134
135impl PackageParser for ConanDataParser {
136    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
137
138    fn is_match(path: &Path) -> bool {
139        path.to_str().is_some_and(|p| p.ends_with("/conandata.yml"))
140    }
141
142    fn extract_packages(path: &Path) -> Vec<PackageData> {
143        let content = match read_file_to_string(path, None) {
144            Ok(c) => c,
145            Err(e) => {
146                warn!("Failed to read conandata.yml file {:?}: {}", path, e);
147                return vec![default_package_data()];
148            }
149        };
150
151        parse_conandata_yml(&content)
152    }
153
154    fn metadata() -> Vec<super::metadata::ParserMetadata> {
155        vec![super::metadata::ParserMetadata {
156            description: "Conan external source metadata",
157            file_patterns: &["*/conandata.yml"],
158            package_type: "conan",
159            primary_language: "C++",
160            documentation_url: Some(
161                "https://docs.conan.io/2/tutorial/creating_packages/handle_sources_in_packages.html",
162            ),
163        }]
164    }
165}
166
167pub(crate) fn parse_conandata_yml(content: &str) -> Vec<PackageData> {
168    let data: ConanDataYml = match yaml_serde::from_str(content) {
169        Ok(d) => d,
170        Err(e) => {
171            warn!("Failed to parse conandata.yml: {}", e);
172            return vec![default_package_data()];
173        }
174    };
175
176    let Some(sources) = data.sources else {
177        return vec![default_package_data()];
178    };
179
180    let mut packages = Vec::new();
181
182    // `sources` is an unordered HashMap; sort by version key so truncation at
183    // the cap drops a deterministic, reproducible set of entries.
184    let limit = capped_iteration_limit(sources.len(), "conandata.yml sources");
185    let mut sources: Vec<_> = sources.into_iter().collect();
186    sources.sort_by(|(a, _), (b, _)| a.cmp(b));
187
188    for (version, sources_value) in sources.into_iter().take(limit) {
189        let source_infos = sources_to_infos(sources_value);
190        let mut extra_data = HashMap::new();
191
192        let primary_index = source_infos
193            .iter()
194            .position(|source_info| {
195                source_info.url.is_some()
196                    || source_info
197                        .sha256
198                        .as_ref()
199                        .is_some_and(|value| !value.is_empty())
200            })
201            .unwrap_or(0);
202
203        let primary_source = source_infos.get(primary_index);
204
205        let download_url = primary_source.and_then(SourceInfo::primary_download_url);
206
207        if let Some(UrlValue::Multiple(urls)) =
208            primary_source.and_then(|source| source.url.as_ref())
209            && urls.len() > 1
210        {
211            let mirror_urls: Vec<_> = urls.iter().cloned().map(truncate_field).collect();
212            extra_data.insert("mirror_urls".to_string(), json!(mirror_urls));
213        }
214
215        if source_infos.len() > 1 {
216            let additional_sources: Vec<_> = source_infos
217                .iter()
218                .enumerate()
219                .filter(|(index, _)| *index != primary_index)
220                .map(|(_, source_info)| source_info.additional_data_json())
221                .collect();
222
223            if !additional_sources.is_empty() {
224                extra_data.insert("additional_sources".to_string(), json!(additional_sources));
225            }
226        }
227
228        if let Some(ref patches_map) = data.patches
229            && let Some(patches_value) = patches_map.get(&version)
230        {
231            let patches_json = match patches_value {
232                PatchesValue::List(patches) => {
233                    let patches_data: Vec<_> = patches
234                        .iter()
235                        .map(|p| {
236                            json!({
237                                "patch_file": p.patch_file,
238                                "patch_description": p.patch_description,
239                                "patch_type": p.patch_type,
240                            })
241                        })
242                        .collect();
243                    json!(patches_data)
244                }
245                PatchesValue::String(s) => json!(s),
246            };
247            extra_data.insert("patches".to_string(), patches_json);
248        }
249
250        packages.push(PackageData {
251            package_type: Some(PACKAGE_TYPE),
252            primary_language: Some("C++".to_string()),
253            version: Some(truncate_field(version)),
254            download_url,
255            sha256: primary_source
256                .and_then(|source_info| source_info.sha256.as_deref())
257                .and_then(|hash| Sha256Digest::from_hex(hash).ok()),
258            extra_data: if extra_data.is_empty() {
259                None
260            } else {
261                Some(extra_data)
262            },
263            datasource_id: Some(DatasourceId::ConanConanDataYml),
264            ..Default::default()
265        });
266    }
267
268    if packages.is_empty() {
269        packages.push(default_package_data());
270    }
271
272    packages
273}