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