1use 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
49pub 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 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}