znippy_common/plugins/native/
conda_native.rs1use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
25use std::collections::HashMap;
26
27pub struct CondaPlugin;
31
32#[cfg(feature = "host-decompressors")]
42struct CondaIndex {
44 name: String,
45 version: String,
46 build: String,
47 subdir: Option<String>,
48}
49
50impl CondaPlugin {
51 fn parse_filename(path: &str) -> (String, Option<String>) {
57 let filename = file_name(path);
58 let stem = filename
59 .strip_suffix(".tar.bz2")
60 .or_else(|| filename.strip_suffix(".conda"))
61 .unwrap_or(filename);
62 let mut parts: Vec<&str> = stem.rsplitn(3, '-').collect();
65 if parts.len() == 3 {
67 let name = parts.pop().unwrap();
68 let version = parts.pop().unwrap();
69 (name.to_string(), Some(version.to_string()))
70 } else {
71 (stem.to_string(), None)
72 }
73 }
74
75 #[cfg(feature = "host-decompressors")]
80 fn parse_tar_bz2(data: &[u8]) -> Option<CondaIndex> {
81 use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
82 use std::io::Read;
83 let tar_bytes = lbzip2::stream::decompress_capped(data, MAX_INGEST_DECOMPRESS).ok()?;
86 let mut archive = tar::Archive::new(&tar_bytes[..]);
88 let mut index_json: Option<Vec<u8>> = None;
89 for entry in archive.entries().ok()? {
90 let mut entry = entry.ok()?;
91 let path = entry.path().ok()?.to_string_lossy().to_string();
92 if path == "info/index.json" || path.ends_with("/info/index.json") {
93 let mut buf = Vec::new();
95 let read = entry
96 .by_ref()
97 .take(MAX_INGEST_DECOMPRESS as u64)
98 .read_to_end(&mut buf)
99 .ok()?;
100 if read >= MAX_INGEST_DECOMPRESS {
101 return None;
102 }
103 index_json = Some(buf);
104 break;
105 }
106 }
107 let index_json = index_json?;
108 let v: serde_json::Value = serde_json::from_slice(&index_json).ok()?;
109 let name = v.get("name")?.as_str()?.to_string();
110 let version = v.get("version")?.as_str()?.to_string();
111 let build = v
112 .get("build")
113 .and_then(|b| b.as_str())
114 .map(|s| s.to_string())
115 .unwrap_or_default();
116 let subdir = v.get("subdir").and_then(|s| s.as_str()).map(|s| s.to_string());
117 if name.is_empty() || version.is_empty() {
118 return None;
119 }
120 Some(CondaIndex { name, version, build, subdir })
121 }
122
123 #[cfg(feature = "host-decompressors")]
127 fn parse_index(path: &str, data: &[u8]) -> Option<CondaIndex> {
128 let filename = file_name(path);
129 if filename.ends_with(".tar.bz2") {
130 return Self::parse_tar_bz2(data);
131 }
132 if filename.ends_with(".conda") {
133 log::info!(
134 "conda: .conda (zip+zstd) index parsing is a follow-up; \
135 falling back to filename coords for {filename}"
136 );
137 }
138 None
139 }
140
141 fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
144 #[cfg(feature = "host-decompressors")]
145 if let Some(idx) = Self::parse_index(path, _data) {
146 return (idx.name, Some(idx.version));
147 }
148 Self::parse_filename(path)
149 }
150
151 fn resolve_build(_path: &str, _data: &[u8]) -> String {
153 #[cfg(feature = "host-decompressors")]
154 if let Some(idx) = Self::parse_index(_path, _data) {
155 return idx.build;
156 }
157 String::new()
158 }
159
160 fn resolve_subdir(_path: &str, _data: &[u8]) -> Option<String> {
162 #[cfg(feature = "host-decompressors")]
163 if let Some(idx) = Self::parse_index(_path, _data) {
164 return idx.subdir;
165 }
166 None
167 }
168}
169
170pub static CONDA_SPEC: HandlerSpec = HandlerSpec {
173 name: "conda",
174 aliases: &["anaconda", "mamba"],
175 type_id: 14,
176 ecosystem: "Conda packages (Anaconda / conda-forge)",
177 extensions: &[".conda", ".tar.bz2"],
178 claim: Claim::Extensions,
179 description: "Conda packages — authoritative name/version/build/subdir from info/index.json (.tar.bz2)",
180 fields: &[("name", Col::Utf8), ("version", Col::Utf8), ("build", Col::Utf8), ("subdir", Col::Utf8)],
181 commands: &[],
182};
183
184impl ArchiveTypePlugin for CondaPlugin {
185 fn spec(&self) -> &HandlerSpec {
186 &CONDA_SPEC
187 }
188
189 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
190 let (name, version) = Self::resolve_coords(path, data);
191 let build = Self::resolve_build(path, data);
192 let subdir = Self::resolve_subdir(path, data);
193 let mut fields = HashMap::new();
194 fields.insert("name".into(), ExtensionValue::Str(name));
195 fields.insert("version".into(), ExtensionValue::OptStr(version));
196 fields.insert("build".into(), ExtensionValue::Str(build));
197 fields.insert("subdir".into(), ExtensionValue::OptStr(subdir));
198 Some(ExtensionRow { fields })
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn matches_conda_extensions() {
208 let p = CondaPlugin;
209 assert!(p.matches_path("linux-64/numpy-1.26.0-py311h1234567_0.tar.bz2"));
210 assert!(p.matches_path("linux-64/numpy-1.26.0-py311h1234567_0.conda"));
211 assert!(!p.matches_path("foo.tgz"));
212 }
213
214 #[test]
215 fn filename_fallback_splits_name_version() {
216 let (n, v) = CondaPlugin::parse_filename("linux-64/numpy-1.26.0-py311h1234567_0.tar.bz2");
217 assert_eq!(n, "numpy");
218 assert_eq!(v.as_deref(), Some("1.26.0"));
219 }
220
221 #[test]
222 fn schema_has_name_version_build_subdir() {
223 let f = CondaPlugin.schema_fields();
224 assert_eq!(f.len(), 4);
225 assert_eq!(f[0].name(), "name");
226 assert_eq!(f[1].name(), "version");
227 assert_eq!(f[2].name(), "build");
228 assert_eq!(f[3].name(), "subdir");
229 }
230}