Skip to main content

provenant/parsers/
conan_data.rs

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