znippy_common/plugins/native/
deb_native.rs1use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
16use std::collections::HashMap;
17
18pub struct DebPlugin;
22
23impl DebPlugin {
24 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 #[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 #[cfg(feature = "host-decompressors")]
59 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 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#[cfg(feature = "host-decompressors")]
94struct ArMember<'a> {
95 name: &'a str,
96 data: &'a [u8],
97}
98
99#[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)?; }
122 None
123}
124
125#[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 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#[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#[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
199pub 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 #[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 #[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 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 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 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 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}