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