Skip to main content

znippy_common/plugins/native/
deb_native.rs

1//! Native Debian plugin.
2//!
3//! A `.deb` is a Unix **ar** archive whose members are `debian-binary`,
4//! `control.tar[.gz|.xz|.zst|.bz2]`, and `data.tar.*`. The authoritative
5//! Package/Version/Architecture (plus Depends, Maintainer, Description, …) live in
6//! the `control` file inside the **control tarball** — NOT the filename. Parsing it
7//! (under the `host-decompressors` feature: `tar` for the inner tar, plus
8//! `lgz`/`lbzip2`/`lzma-rs`/`ruzstd` for the gz/bz2/xz/zst codecs) is what lets the
9//! read-side [`DebView`](crate::views::DebView) surface real control metadata.
10//!
11//! On any failure (off-feature, unknown codec, malformed) the plugin falls back to
12//! the `{name}_{version}_{arch}.deb` filename — never panics, always returns a row,
13//! exactly like the `gem`/`conda` native plugins.
14
15use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
16use arrow::datatypes::{DataType, Field};
17use std::collections::HashMap;
18
19/// Native deb plugin. `name`/`version`/`arch` + the raw `control` stanza come from
20/// the control tarball when the `.deb` is parseable, else `(name, version, arch)`
21/// from the filename (no control stanza).
22pub struct DebPlugin;
23
24impl DebPlugin {
25    /// `{name}_{version}_{arch}.deb` filename fallback. Neither the package name
26    /// nor the version contains `_` in a valid pool filename, so a 3-way split is
27    /// exact.
28    fn parse_filename(path: &str) -> (String, Option<String>, Option<String>) {
29        let fname = path.rsplit('/').next().unwrap_or(path);
30        let stem = fname
31            .strip_suffix(".deb")
32            .or_else(|| fname.strip_suffix(".udeb"))
33            .unwrap_or(fname);
34        let mut it = stem.splitn(3, '_');
35        let name = it.next().unwrap_or(stem).to_string();
36        let version = it.next().filter(|s| !s.is_empty()).map(str::to_string);
37        let arch = it.next().filter(|s| !s.is_empty()).map(str::to_string);
38        (name, version, arch)
39    }
40
41    /// Parse the authoritative `control` stanza out of a `.deb` (ar → control
42    /// tarball → `control`). Returns the raw stanza text. `None` on any failure.
43    #[cfg(feature = "host-decompressors")]
44    fn parse_control(data: &[u8]) -> Option<String> {
45        let member = ar_find_member(data, "control.tar")?;
46        let tar_bytes = decompress_control(member.name, member.data)?;
47        let control = tar_find_file(&tar_bytes, "control")?;
48        let text = String::from_utf8_lossy(&control).into_owned();
49        text.lines().any(|l| l.starts_with("Package:")).then_some(text)
50    }
51
52    // Called only from `resolve` and from the control-stanza test, both of
53    // which already carry `#[cfg(feature = "host-decompressors")]`. Same gate,
54    // same reason as `CondaIndex` in `conda_native.rs`: without it the default
55    // build warned ``associated function `control_coords` is never used``,
56    // which was true of that build. A gate, not an `allow` — the function is
57    // genuinely absent without the decompressors, and an `allow` would have
58    // hidden a real disuse later.
59    #[cfg(feature = "host-decompressors")]
60    /// `(name, version, arch)` from a control stanza's single-line Package /
61    /// Version / Architecture fields.
62    fn control_coords(control: &str) -> (Option<String>, Option<String>, Option<String>) {
63        let field = |key: &str| -> Option<String> {
64            control
65                .lines()
66                .find_map(|l| l.strip_prefix(key))
67                .map(|v| v.trim().to_string())
68                .filter(|s| !s.is_empty())
69        };
70        (field("Package:"), field("Version:"), field("Architecture:"))
71    }
72
73    /// Resolve coords + the raw control stanza: authoritative control first,
74    /// filename fallback (no control) otherwise.
75    fn resolve(
76        path: &str,
77        data: &[u8],
78    ) -> (String, Option<String>, Option<String>, Option<String>) {
79        #[cfg(feature = "host-decompressors")]
80        if let Some(control) = Self::parse_control(data) {
81            let (n, v, a) = Self::control_coords(&control);
82            if let Some(name) = n {
83                return (name, v, a, Some(control));
84            }
85        }
86        let _ = data;
87        let (name, version, arch) = Self::parse_filename(path);
88        (name, version, arch, None)
89    }
90}
91
92// ── ar + control-tarball helpers (host-decompressors only) ──────────────────
93
94#[cfg(feature = "host-decompressors")]
95struct ArMember<'a> {
96    name: &'a str,
97    data: &'a [u8],
98}
99
100/// Find the first ar member whose (trimmed) name starts with `name_prefix`. The
101/// `.deb` ar uses short, space-padded names (`control.tar.xz`, `debian-binary`).
102/// Fully bounds-checked; `None` on any malformation.
103#[cfg(feature = "host-decompressors")]
104fn ar_find_member<'a>(data: &'a [u8], name_prefix: &str) -> Option<ArMember<'a>> {
105    if data.get(0..8)? != b"!<arch>\n" {
106        return None;
107    }
108    let mut pos = 8usize;
109    while pos.checked_add(60)? <= data.len() {
110        let hdr = &data[pos..pos + 60];
111        let name = std::str::from_utf8(&hdr[0..16]).ok()?.trim_end().trim_end_matches('/');
112        let size: usize = std::str::from_utf8(&hdr[48..58]).ok()?.trim().parse().ok()?;
113        let dstart = pos.checked_add(60)?;
114        let dend = dstart.checked_add(size)?;
115        if dend > data.len() {
116            return None;
117        }
118        if name.starts_with(name_prefix) {
119            return Some(ArMember { name, data: &data[dstart..dend] });
120        }
121        pos = dend.checked_add(size & 1)?; // members are padded to an even boundary
122    }
123    None
124}
125
126/// Decompress a control tarball member by its codec extension: uncompressed, gz
127/// (lgz), bz2 (lbzip2), xz (lzma-rs), zst (ruzstd). Any unknown codec returns
128/// `None` (the caller falls back to the filename).
129#[cfg(feature = "host-decompressors")]
130fn decompress_control(name: &str, data: &[u8]) -> Option<Vec<u8>> {
131    use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
132    use std::io::Read;
133    if name == "control.tar" {
134        Some(data.to_vec())
135    } else if name.ends_with(".gz") {
136        lgz::decompress_gz_capped(data, MAX_INGEST_DECOMPRESS).ok()
137    } else if name.ends_with(".xz") {
138        let mut out = Vec::new();
139        let mut w = CappedWriter { out: &mut out, cap: MAX_INGEST_DECOMPRESS };
140        lzma_rs::xz_decompress(&mut std::io::Cursor::new(data), &mut w).ok()?;
141        Some(out)
142    } else if name.ends_with(".bz2") {
143        lbzip2::stream::decompress_capped(data, MAX_INGEST_DECOMPRESS).ok()
144    } else if name.ends_with(".zst") {
145        // Pure-Rust zstd (ruzstd), capped so a bomb can't OOM at ingest.
146        let dec = ruzstd::StreamingDecoder::new(std::io::Cursor::new(data)).ok()?;
147        let mut out = Vec::new();
148        dec.take(MAX_INGEST_DECOMPRESS as u64).read_to_end(&mut out).ok()?;
149        (out.len() < MAX_INGEST_DECOMPRESS).then_some(out)
150    } else {
151        None
152    }
153}
154
155/// Extract the `control` member (`control` or `./control`) from an uncompressed
156/// control tarball, capped so a tar bomb can't OOM at ingest.
157#[cfg(feature = "host-decompressors")]
158fn tar_find_file(tar_bytes: &[u8], want: &str) -> Option<Vec<u8>> {
159    use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
160    use std::io::Read;
161    let mut archive = tar::Archive::new(tar_bytes);
162    for entry in archive.entries().ok()? {
163        let mut entry = entry.ok()?;
164        let path = entry.path().ok()?.to_string_lossy().to_string();
165        if path.trim_start_matches("./") == want {
166            let mut buf = Vec::new();
167            let read =
168                entry.by_ref().take(MAX_INGEST_DECOMPRESS as u64).read_to_end(&mut buf).ok()?;
169            if read >= MAX_INGEST_DECOMPRESS {
170                return None;
171            }
172            return Some(buf);
173        }
174    }
175    None
176}
177
178/// An `io::Write` that errors once `cap` bytes have been written — bounds a
179/// decompression bomb at ingest (the streaming codecs write into this).
180#[cfg(feature = "host-decompressors")]
181struct CappedWriter<'a> {
182    out: &'a mut Vec<u8>,
183    cap: usize,
184}
185
186#[cfg(feature = "host-decompressors")]
187impl std::io::Write for CappedWriter<'_> {
188    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
189        if self.out.len().saturating_add(buf.len()) > self.cap {
190            return Err(std::io::Error::other("control tarball decompress cap exceeded"));
191        }
192        self.out.extend_from_slice(buf);
193        Ok(buf.len())
194    }
195    fn flush(&mut self) -> std::io::Result<()> {
196        Ok(())
197    }
198}
199
200impl ArchiveTypePlugin for DebPlugin {
201    fn name(&self) -> &str {
202        "deb"
203    }
204
205    fn type_id(&self) -> i8 {
206        9
207    }
208
209    fn meta(&self) -> HandlerMeta {
210        HandlerMeta {
211            name: "deb".into(),
212            aliases: vec!["debian".into(), "ubuntu".into(), "apt".into(), "dpkg".into()],
213            type_id: 9,
214            ecosystem: "Debian packages (Debian / Ubuntu)".into(),
215            extensions: vec![".deb".into(), ".udeb".into()],
216            description: "Debian packages — authoritative control fields from the control tarball"
217                .into(),
218            commands: vec![HandlerCommand::new(
219                "coords",
220                "Print deb name + version (control if readable, else filename)",
221            )],
222        }
223    }
224
225    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
226        match cmd {
227            "coords" => {
228                let path =
229                    args.first().ok_or_else(|| anyhow::anyhow!("usage: deb coords <file.deb>"))?;
230                let (name, version, _arch) = Self::parse_filename(path);
231                match version {
232                    Some(v) => println!("{} {}", name, v),
233                    None => println!("{}", name),
234                }
235                Ok(())
236            }
237            other => anyhow::bail!("deb: unknown subcommand '{}'", other),
238        }
239    }
240
241    fn matches_path(&self, path: &str) -> bool {
242        path.ends_with(".deb") || path.ends_with(".udeb")
243    }
244
245    /// Columns the read-side [`DebView`](crate::views::DebView) resolves on: the
246    /// coords plus the raw `control` stanza (the real Depends/Maintainer/Description
247    /// the filename can't carry).
248    fn schema_fields(&self) -> Vec<Field> {
249        vec![
250            Field::new("name", DataType::Utf8, true),
251            Field::new("version", DataType::Utf8, true),
252            Field::new("arch", DataType::Utf8, true),
253            Field::new("control", DataType::Utf8, true),
254        ]
255    }
256
257    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
258        let (name, version, arch, control) = Self::resolve(path, data);
259        let mut fields = HashMap::new();
260        fields.insert("name".into(), ExtensionValue::Str(name));
261        fields.insert("version".into(), ExtensionValue::OptStr(version));
262        fields.insert("arch".into(), ExtensionValue::OptStr(arch));
263        fields.insert("control".into(), ExtensionValue::OptStr(control));
264        Some(ExtensionRow { fields })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn filename_fallback_splits_name_version_arch() {
274        let (n, v, a) = DebPlugin::parse_filename("pool/main/h/hello/hello_2.10-3_amd64.deb");
275        assert_eq!(n, "hello");
276        assert_eq!(v.as_deref(), Some("2.10-3"));
277        assert_eq!(a.as_deref(), Some("amd64"));
278    }
279
280    #[test]
281    fn matches_deb_and_udeb_only() {
282        assert!(DebPlugin.matches_path("pool/main/x_1_amd64.deb"));
283        assert!(DebPlugin.matches_path("pool/main/x_1_amd64.udeb"));
284        assert!(!DebPlugin.matches_path("foo.rpm"));
285    }
286
287    #[test]
288    fn schema_has_name_version_arch_control() {
289        let f = DebPlugin.schema_fields();
290        let names: Vec<&str> = f.iter().map(|x| x.name().as_str()).collect();
291        assert_eq!(names, vec!["name", "version", "arch", "control"]);
292    }
293
294    #[test]
295    fn extract_falls_back_to_filename_for_garbage() {
296        let row = DebPlugin
297            .extract_metadata("pool/main/zlib_1.2.11_amd64.deb", b"not a deb")
298            .expect("row");
299        assert_eq!(row.fields.get("name"), Some(&ExtensionValue::Str("zlib".into())));
300        assert_eq!(
301            row.fields.get("version"),
302            Some(&ExtensionValue::OptStr(Some("1.2.11".into())))
303        );
304        assert_eq!(row.fields.get("control"), Some(&ExtensionValue::OptStr(None)));
305    }
306
307    // ── host-decompressors: real control-tarball parsing ────────────────────
308
309    /// Build a one-file `control` tar (uncompressed `ustar`).
310    #[cfg(feature = "host-decompressors")]
311    fn control_tar(control: &str) -> Vec<u8> {
312        let mut b = tar::Builder::new(Vec::new());
313        let mut header = tar::Header::new_ustar();
314        header.set_path("./control").unwrap();
315        header.set_size(control.len() as u64);
316        header.set_mode(0o644);
317        header.set_cksum();
318        b.append(&header, control.as_bytes()).unwrap();
319        b.into_inner().unwrap()
320    }
321
322    /// Wrap members into a `.deb` ar archive (`!<arch>\n` + 60-byte headers).
323    #[cfg(feature = "host-decompressors")]
324    fn ar_build(members: &[(&str, &[u8])]) -> Vec<u8> {
325        let mut out = b"!<arch>\n".to_vec();
326        for (name, data) in members {
327            let mut hdr = [b' '; 60];
328            let nb = name.as_bytes();
329            hdr[0..nb.len()].copy_from_slice(nb);
330            let size = format!("{}", data.len());
331            hdr[48..48 + size.len()].copy_from_slice(size.as_bytes());
332            hdr[58] = b'`';
333            hdr[59] = b'\n';
334            out.extend_from_slice(&hdr);
335            out.extend_from_slice(data);
336            if data.len() % 2 == 1 {
337                out.push(b'\n');
338            }
339        }
340        out
341    }
342
343    const SAMPLE_CONTROL: &str = "Package: hello\n\
344        Version: 2.10-3\n\
345        Architecture: amd64\n\
346        Maintainer: Someone <a@b.c>\n\
347        Depends: libc6 (>= 2.2.5)\n\
348        Description: example\n\
349        \x20more description\n";
350
351    #[cfg(feature = "host-decompressors")]
352    #[test]
353    fn parses_control_from_uncompressed_control_tar() {
354        let tar = control_tar(SAMPLE_CONTROL);
355        let deb = ar_build(&[("debian-binary", b"2.0\n"), ("control.tar", &tar)]);
356        let control = DebPlugin::parse_control(&deb).expect("parses");
357        let (n, v, a) = DebPlugin::control_coords(&control);
358        assert_eq!(n.as_deref(), Some("hello"));
359        assert_eq!(v.as_deref(), Some("2.10-3"));
360        assert_eq!(a.as_deref(), Some("amd64"));
361        assert!(control.contains("Depends: libc6 (>= 2.2.5)"), "real Depends flows through");
362    }
363
364    #[cfg(feature = "host-decompressors")]
365    #[test]
366    fn parses_control_from_xz_control_tar() {
367        // dpkg's modern default control compression.
368        let tar = control_tar(SAMPLE_CONTROL);
369        let mut xz = Vec::new();
370        lzma_rs::xz_compress(&mut std::io::Cursor::new(&tar), &mut xz).unwrap();
371        let deb = ar_build(&[("debian-binary", b"2.0\n"), ("control.tar.xz", &xz)]);
372        let row = DebPlugin.extract_metadata("pool/main/wrong_0_all.deb", &deb).expect("row");
373        // Header is authoritative over the (deliberately wrong) filename.
374        assert_eq!(row.fields.get("name"), Some(&ExtensionValue::Str("hello".into())));
375        assert_eq!(
376            row.fields.get("version"),
377            Some(&ExtensionValue::OptStr(Some("2.10-3".into())))
378        );
379        match row.fields.get("control") {
380            Some(ExtensionValue::OptStr(Some(c))) => assert!(c.contains("Maintainer:")),
381            other => panic!("expected control stanza, got {other:?}"),
382        }
383    }
384
385    #[cfg(feature = "host-decompressors")]
386    #[test]
387    fn parses_control_from_zst_control_tar() {
388        // Newer Ubuntu / dpkg zstd control compression, decoded via ruzstd. The
389        // committed fixture is `zstd(tar(control))` for a `Package: hello` control.
390        let zst = include_bytes!("testdata/control.tar.zst");
391        let deb = ar_build(&[("debian-binary", b"2.0\n"), ("control.tar.zst", zst)]);
392        let row = DebPlugin.extract_metadata("pool/main/wrong_0_all.deb", &deb).expect("row");
393        assert_eq!(row.fields.get("name"), Some(&ExtensionValue::Str("hello".into())));
394        assert_eq!(
395            row.fields.get("version"),
396            Some(&ExtensionValue::OptStr(Some("2.10-3".into())))
397        );
398        match row.fields.get("control") {
399            Some(ExtensionValue::OptStr(Some(c))) => {
400                assert!(c.contains("Depends: libc6 (>= 2.2.5)"), "real control from the .zst");
401            }
402            other => panic!("expected control stanza, got {other:?}"),
403        }
404    }
405
406    #[cfg(feature = "host-decompressors")]
407    #[test]
408    fn unknown_codec_falls_back_to_filename() {
409        // A codec we don't support (e.g. lzip) must degrade to the filename, not error.
410        let deb = ar_build(&[("debian-binary", b"2.0\n"), ("control.tar.lz", b"\x00\x01\x02")]);
411        let (n, v, a, control) = DebPlugin::resolve("pool/main/curl_8.5.0_arm64.deb", &deb);
412        assert_eq!(n, "curl");
413        assert_eq!(v.as_deref(), Some("8.5.0"));
414        assert_eq!(a.as_deref(), Some("arm64"));
415        assert!(control.is_none(), "no control stanza for an unsupported codec");
416    }
417
418    #[cfg(feature = "host-decompressors")]
419    #[test]
420    fn malformed_ar_never_panics() {
421        for bad in [&b"!<arch>\n"[..], b"not ar at all", b"!<arch>\nshort"] {
422            assert!(DebPlugin::parse_control(bad).is_none());
423        }
424    }
425}