Skip to main content

provenant/parsers/
conda_meta_json.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 Conda metadata JSON files.
7//!
8//! Extracts package metadata from `conda-meta/*.json` files which contain
9//! installed package information in Conda environments.
10//!
11//! # Supported Formats
12//! - `conda-meta/*.json` - Conda installed package metadata
13//!
14//! # Key Features
15//! - Installed package identification
16//! - License extraction
17//! - Download URLs and checksums
18//!
19//! # Implementation Notes
20//! - Format: JSON with package metadata
21//! - Located in conda-meta/ directory in rootfs
22//! - Spec: https://docs.conda.io/
23
24use crate::models::{DatasourceId, FileReference, Md5Digest, PackageType, Sha256Digest};
25use std::collections::HashMap;
26use std::path::Path;
27
28use crate::parser_warn as warn;
29use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use crate::models::PackageData;
34
35use super::PackageParser;
36
37const PACKAGE_TYPE: PackageType = PackageType::Conda;
38
39fn default_package_data() -> PackageData {
40    PackageData {
41        package_type: Some(PACKAGE_TYPE),
42        primary_language: Some("Python".to_string()),
43        datasource_id: Some(DatasourceId::CondaMetaJson),
44        ..Default::default()
45    }
46}
47
48/// Parser for Conda metadata JSON files
49pub struct CondaMetaJsonParser;
50
51#[derive(Debug, Deserialize, Serialize)]
52struct CondaMetaJson {
53    name: Option<String>,
54    version: Option<String>,
55    license: Option<String>,
56    url: Option<String>,
57    size: Option<u64>,
58    md5: Option<String>,
59    sha256: Option<String>,
60    requested_spec: Option<String>,
61    channel: Option<String>,
62    extracted_package_dir: Option<String>,
63    files: Option<Vec<String>>,
64    package_tarball_full_path: Option<String>,
65    #[serde(flatten)]
66    other: HashMap<String, Value>,
67}
68
69impl PackageParser for CondaMetaJsonParser {
70    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
71
72    fn is_match(path: &Path) -> bool {
73        path.extension().and_then(|ext| ext.to_str()) == Some("json")
74            && !path
75                .file_name()
76                .and_then(|name| name.to_str())
77                .is_some_and(|name| name.ends_with("expected.json"))
78            && path
79                .components()
80                .any(|component| component.as_os_str() == "conda-meta")
81    }
82
83    fn extract_packages(path: &Path) -> Vec<PackageData> {
84        let content = match read_file_to_string(path, None) {
85            Ok(c) => c,
86            Err(e) => {
87                warn!("Failed to read conda-meta JSON file {:?}: {}", path, e);
88                return vec![default_package_data()];
89            }
90        };
91
92        vec![parse_conda_meta_json(&content)]
93    }
94
95    fn metadata() -> Vec<super::metadata::ParserMetadata> {
96        vec![super::metadata::ParserMetadata {
97            description: "Conda installed package metadata JSON",
98            file_patterns: &["*conda-meta/*.json"],
99            package_type: "conda",
100            primary_language: "Python",
101            documentation_url: Some("https://docs.conda.io/"),
102        }]
103    }
104}
105
106pub(crate) fn parse_conda_meta_json(content: &str) -> PackageData {
107    parse_conda_meta_json_with_path(content, None)
108}
109
110pub(crate) fn parse_conda_meta_json_with_path(content: &str, _path: Option<&Path>) -> PackageData {
111    let metadata: CondaMetaJson = match serde_json::from_str(content) {
112        Ok(m) => m,
113        Err(e) => {
114            warn!("Failed to parse conda-meta JSON: {}", e);
115            return default_package_data();
116        }
117    };
118
119    // Build extra_data with specific fields
120    let mut extra_data = HashMap::new();
121    if let Some(ref requested_spec) = metadata.requested_spec {
122        extra_data.insert(
123            "requested_spec".to_string(),
124            Value::String(truncate_field(requested_spec.clone())),
125        );
126    }
127    if let Some(ref channel) = metadata.channel {
128        extra_data.insert(
129            "channel".to_string(),
130            Value::String(truncate_field(channel.clone())),
131        );
132    }
133    if let Some(ref extracted_package_dir) = metadata.extracted_package_dir {
134        extra_data.insert(
135            "extracted_package_dir".to_string(),
136            Value::String(truncate_field(extracted_package_dir.clone())),
137        );
138    }
139    if let Some(ref files) = metadata.files {
140        extra_data.insert(
141            "files".to_string(),
142            Value::Array(
143                files
144                    .iter()
145                    .map(|f| Value::String(truncate_field(f.clone())))
146                    .collect(),
147            ),
148        );
149    }
150    if let Some(ref package_tarball_full_path) = metadata.package_tarball_full_path {
151        extra_data.insert(
152            "package_tarball_full_path".to_string(),
153            Value::String(truncate_field(package_tarball_full_path.clone())),
154        );
155    }
156
157    let extra_data_opt = if extra_data.is_empty() {
158        None
159    } else {
160        Some(extra_data)
161    };
162
163    let purl = metadata.name.as_deref().and_then(|name| {
164        super::conda::build_purl("conda", None, name, metadata.version.as_deref(), None)
165    });
166
167    let file_references = build_conda_file_references(
168        metadata.extracted_package_dir.as_deref(),
169        metadata.package_tarball_full_path.as_deref(),
170        metadata.files.as_deref(),
171    );
172
173    PackageData {
174        package_type: Some(PACKAGE_TYPE),
175        primary_language: Some("Python".to_string()),
176        name: metadata.name.map(truncate_field),
177        version: metadata.version.map(truncate_field),
178        extracted_license_statement: metadata.license.map(truncate_field),
179        download_url: metadata.url.map(truncate_field),
180        size: metadata.size,
181        md5: metadata.md5.and_then(|h| Md5Digest::from_hex(&h).ok()),
182        sha256: metadata
183            .sha256
184            .and_then(|h| Sha256Digest::from_hex(&h).ok()),
185        extra_data: extra_data_opt,
186        file_references,
187        datasource_id: Some(DatasourceId::CondaMetaJson),
188        purl,
189        ..Default::default()
190    }
191}
192
193fn build_conda_file_references(
194    extracted_package_dir: Option<&str>,
195    package_tarball_full_path: Option<&str>,
196    files: Option<&[String]>,
197) -> Vec<FileReference> {
198    let mut refs = Vec::new();
199
200    if let Some(extracted_dir) = extracted_package_dir
201        && let Some(relative) = condense_to_pkgs_relative(extracted_dir)
202    {
203        refs.push(FileReference {
204            path: truncate_field(relative),
205            size: None,
206            sha1: None,
207            md5: None,
208            sha256: None,
209            sha512: None,
210            extra_data: None,
211        });
212    }
213
214    if let Some(tarball) = package_tarball_full_path
215        && let Some(relative) = condense_to_pkgs_relative(tarball)
216    {
217        refs.push(FileReference {
218            path: truncate_field(relative),
219            size: None,
220            sha1: None,
221            md5: None,
222            sha256: None,
223            sha512: None,
224            extra_data: None,
225        });
226    }
227
228    if let Some(files) = files {
229        let limit = capped_iteration_limit(files.len(), "conda meta files");
230        for file in files.iter().take(limit) {
231            refs.push(FileReference {
232                path: truncate_field(file.clone()),
233                size: None,
234                sha1: None,
235                md5: None,
236                sha256: None,
237                sha512: None,
238                extra_data: None,
239            });
240        }
241    }
242
243    refs
244}
245
246fn condense_to_pkgs_relative(path: &str) -> Option<String> {
247    let normalized = path.replace('\\', "/");
248    let relative = normalized.split("/pkgs/").nth(1)?;
249    Some(format!("pkgs/{}", relative.trim_start_matches('/')))
250}