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