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