Skip to main content

znippy_common/plugins/native/
conda_native.rs

1//! Native Conda plugin.
2//!
3//! Handles the classic **`.tar.bz2`** conda package: a bzip2-compressed tar whose
4//! `info/index.json` carries the **authoritative** `name`, `version`, `build`, and
5//! `subdir`. These are NOT reliably recoverable from the filename: a conda file is
6//! named `{name}-{version}-{build}.tar.bz2`, and `build` itself contains hyphens
7//! and digits (`py311h1234567_0`), so a naive split mis-attributes the version.
8//! Parsing `info/index.json` (under the `host-decompressors` feature, via `lbzip2`
9//! for the bunzip2 + the `tar` crate for the untar) is what lets the read-side
10//! [`CondaView`](crate::views::CondaView) resolve conda coords correctly.
11//!
12//! The newer **`.conda`** format (a zip whose members are zstd-compressed tars
13//! `info-*.tar.zst`) is recognized by extension, but its `info/index.json` is NOT
14//! parsed here: extracting it needs a zip reader plus a zstd-tar path that the
15//! available `host-decompressors` deps (`lgz`/`lbzip2`/`tar`) do not provide. For a
16//! `.conda` file the plugin therefore falls back to the filename split and logs a
17//! one-line note. Real `.conda` index parsing is a documented follow-up — we do
18//! NOT fake the columns.
19//!
20//! When `host-decompressors` is off (or the package can't be parsed), the plugin
21//! falls back to a best-effort `(name, version)` split from the filename — never
22//! panics, always returns a row.
23
24use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
25use arrow::datatypes::{DataType, Field};
26use std::collections::HashMap;
27
28/// Native conda plugin. `name`/`version`/`build`/`subdir` come from
29/// `info/index.json` for `.tar.bz2` packages when parseable, else from the
30/// filename.
31pub struct CondaPlugin;
32
33// Only ever built by `parse_tar_bz2` / `parse_index`, both of which already
34// carry `#[cfg(feature = "host-decompressors")]`. Without the same gate here
35// the default build warned ``struct `CondaIndex` is never constructed`` — a
36// TRUE statement about that build, and the warning every other item in this
37// file already avoids by carrying the gate. Gated rather than
38// `#[allow(dead_code)]`-ed: the type genuinely is absent from a build without
39// the decompressors, and an `allow` would also have swallowed the day it goes
40// unused in a build WITH them, which is the only version of this warning worth
41// hearing.
42#[cfg(feature = "host-decompressors")]
43/// Authoritative conda coords parsed from `info/index.json`.
44struct CondaIndex {
45    name: String,
46    version: String,
47    build: String,
48    subdir: Option<String>,
49}
50
51impl CondaPlugin {
52    /// Best-effort `(name, version)` from a conda filename like
53    /// `{name}-{version}-{build}.tar.bz2` / `.conda`: strip the extension, then
54    /// split off the trailing `-{build}` and the `-{version}` before it. The build
55    /// string is NOT a distinct field here — that's exactly why `info/index.json`
56    /// is preferred.
57    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        // `name-version-build`: peel the last two `-`-separated segments as
64        // build + version, leaving the (possibly hyphenated) name.
65        let mut parts: Vec<&str> = stem.rsplitn(3, '-').collect();
66        // rsplitn yields [build, version, name] reversed.
67        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    /// Parse `info/index.json` out of a `.tar.bz2` conda package: bunzip2 the
77    /// outer layer, untar, find `info/index.json`, read the fields. `None` on any
78    /// failure (caller falls back to the filename). Only compiled when host
79    /// decompressors are available.
80    #[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        // 1. bunzip2 the outer layer — capped so a bzip2 compression bomb errors
85        //    here (→ filename fallback) instead of expanding to GB and OOM-aborting.
86        let tar_bytes = lbzip2::stream::decompress_capped(data, MAX_INGEST_DECOMPRESS).ok()?;
87        // 2. untar and find info/index.json.
88        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                // Cap the per-tar-entry read so a huge index member can't OOM.
95                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    /// Parse the authoritative index for a conda package, if possible. `.tar.bz2`
125    /// is fully supported; `.conda` is recognized but not yet parsed (documented
126    /// follow-up) — returns `None` so the caller uses the filename.
127    #[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    /// Resolve `(name, version)`: authoritative `info/index.json` first
143    /// (`.tar.bz2`), filename fallback otherwise.
144    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    /// Resolve the conda build string (empty when unknown / filename mode).
153    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    /// Resolve the conda subdir (platform, e.g. `linux-64`). `None` when unknown.
162    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    /// Columns this handler contributes — the conda coords the read-side
219    /// [`CondaView`](crate::views::CondaView) resolves on, plus the authoritative
220    /// `build` and `subdir` (platform).
221    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}