znippy_common/plugins/native/
conda_native.rs1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
25use arrow::datatypes::{DataType, Field};
26use std::collections::HashMap;
27
28pub struct CondaPlugin;
32
33#[cfg(feature = "host-decompressors")]
43struct CondaIndex {
45 name: String,
46 version: String,
47 build: String,
48 subdir: Option<String>,
49}
50
51impl CondaPlugin {
52 fn parse_filename(path: &str) -> (String, Option<String>) {
58 let filename = path.rsplit('/').next().unwrap_or(path);
59 let stem = filename
60 .strip_suffix(".tar.bz2")
61 .or_else(|| filename.strip_suffix(".conda"))
62 .unwrap_or(filename);
63 let mut parts: Vec<&str> = stem.rsplitn(3, '-').collect();
66 if parts.len() == 3 {
68 let name = parts.pop().unwrap();
69 let version = parts.pop().unwrap();
70 (name.to_string(), Some(version.to_string()))
71 } else {
72 (stem.to_string(), None)
73 }
74 }
75
76 #[cfg(feature = "host-decompressors")]
81 fn parse_tar_bz2(data: &[u8]) -> Option<CondaIndex> {
82 use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
83 use std::io::Read;
84 let tar_bytes = lbzip2::stream::decompress_capped(data, MAX_INGEST_DECOMPRESS).ok()?;
87 let mut archive = tar::Archive::new(&tar_bytes[..]);
89 let mut index_json: Option<Vec<u8>> = None;
90 for entry in archive.entries().ok()? {
91 let mut entry = entry.ok()?;
92 let path = entry.path().ok()?.to_string_lossy().to_string();
93 if path == "info/index.json" || path.ends_with("/info/index.json") {
94 let mut buf = Vec::new();
96 let read = entry
97 .by_ref()
98 .take(MAX_INGEST_DECOMPRESS as u64)
99 .read_to_end(&mut buf)
100 .ok()?;
101 if read >= MAX_INGEST_DECOMPRESS {
102 return None;
103 }
104 index_json = Some(buf);
105 break;
106 }
107 }
108 let index_json = index_json?;
109 let v: serde_json::Value = serde_json::from_slice(&index_json).ok()?;
110 let name = v.get("name")?.as_str()?.to_string();
111 let version = v.get("version")?.as_str()?.to_string();
112 let build = v
113 .get("build")
114 .and_then(|b| b.as_str())
115 .map(|s| s.to_string())
116 .unwrap_or_default();
117 let subdir = v.get("subdir").and_then(|s| s.as_str()).map(|s| s.to_string());
118 if name.is_empty() || version.is_empty() {
119 return None;
120 }
121 Some(CondaIndex { name, version, build, subdir })
122 }
123
124 #[cfg(feature = "host-decompressors")]
128 fn parse_index(path: &str, data: &[u8]) -> Option<CondaIndex> {
129 let filename = path.rsplit('/').next().unwrap_or(path);
130 if filename.ends_with(".tar.bz2") {
131 return Self::parse_tar_bz2(data);
132 }
133 if filename.ends_with(".conda") {
134 log::info!(
135 "conda: .conda (zip+zstd) index parsing is a follow-up; \
136 falling back to filename coords for {filename}"
137 );
138 }
139 None
140 }
141
142 fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
145 #[cfg(feature = "host-decompressors")]
146 if let Some(idx) = Self::parse_index(path, _data) {
147 return (idx.name, Some(idx.version));
148 }
149 Self::parse_filename(path)
150 }
151
152 fn resolve_build(_path: &str, _data: &[u8]) -> String {
154 #[cfg(feature = "host-decompressors")]
155 if let Some(idx) = Self::parse_index(_path, _data) {
156 return idx.build;
157 }
158 String::new()
159 }
160
161 fn resolve_subdir(_path: &str, _data: &[u8]) -> Option<String> {
163 #[cfg(feature = "host-decompressors")]
164 if let Some(idx) = Self::parse_index(_path, _data) {
165 return idx.subdir;
166 }
167 None
168 }
169}
170
171impl ArchiveTypePlugin for CondaPlugin {
172 fn name(&self) -> &str {
173 "conda"
174 }
175
176 fn type_id(&self) -> i8 {
177 14
178 }
179
180 fn meta(&self) -> HandlerMeta {
181 HandlerMeta {
182 name: "conda".into(),
183 aliases: vec!["anaconda".into(), "mamba".into()],
184 type_id: 14,
185 ecosystem: "Conda packages (Anaconda / conda-forge)".into(),
186 extensions: vec![".conda".into(), ".tar.bz2".into()],
187 description:
188 "Conda packages — authoritative name/version/build/subdir from info/index.json (.tar.bz2)"
189 .into(),
190 commands: vec![HandlerCommand::new(
191 "coords",
192 "Print conda package name + version (info/index.json if readable, else filename)",
193 )],
194 }
195 }
196
197 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
198 match cmd {
199 "coords" => {
200 let path = args
201 .first()
202 .ok_or_else(|| anyhow::anyhow!("usage: conda coords <file.tar.bz2|.conda>"))?;
203 let (name, version) = Self::parse_filename(path);
204 match version {
205 Some(v) => println!("{} {}", name, v),
206 None => println!("{}", name),
207 }
208 Ok(())
209 }
210 other => anyhow::bail!("conda: unknown subcommand '{}'", other),
211 }
212 }
213
214 fn matches_path(&self, path: &str) -> bool {
215 path.ends_with(".tar.bz2") || path.ends_with(".conda")
216 }
217
218 fn schema_fields(&self) -> Vec<Field> {
222 vec![
223 Field::new("name", DataType::Utf8, true),
224 Field::new("version", DataType::Utf8, true),
225 Field::new("build", DataType::Utf8, true),
226 Field::new("subdir", DataType::Utf8, true),
227 ]
228 }
229
230 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
231 let (name, version) = Self::resolve_coords(path, data);
232 let build = Self::resolve_build(path, data);
233 let subdir = Self::resolve_subdir(path, data);
234 let mut fields = HashMap::new();
235 fields.insert("name".into(), ExtensionValue::Str(name));
236 fields.insert("version".into(), ExtensionValue::OptStr(version));
237 fields.insert("build".into(), ExtensionValue::Str(build));
238 fields.insert("subdir".into(), ExtensionValue::OptStr(subdir));
239 Some(ExtensionRow { fields })
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn matches_conda_extensions() {
249 let p = CondaPlugin;
250 assert!(p.matches_path("linux-64/numpy-1.26.0-py311h1234567_0.tar.bz2"));
251 assert!(p.matches_path("linux-64/numpy-1.26.0-py311h1234567_0.conda"));
252 assert!(!p.matches_path("foo.tgz"));
253 }
254
255 #[test]
256 fn filename_fallback_splits_name_version() {
257 let (n, v) = CondaPlugin::parse_filename("linux-64/numpy-1.26.0-py311h1234567_0.tar.bz2");
258 assert_eq!(n, "numpy");
259 assert_eq!(v.as_deref(), Some("1.26.0"));
260 }
261
262 #[test]
263 fn schema_has_name_version_build_subdir() {
264 let f = CondaPlugin.schema_fields();
265 assert_eq!(f.len(), 4);
266 assert_eq!(f[0].name(), "name");
267 assert_eq!(f[1].name(), "version");
268 assert_eq!(f[2].name(), "build");
269 assert_eq!(f[3].name(), "subdir");
270 }
271}