Skip to main content

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