znippy_common/plugins/native/
deb_native.rs1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
16use arrow::datatypes::{DataType, Field};
17use std::collections::HashMap;
18
19pub struct DebPlugin;
23
24impl DebPlugin {
25 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 #[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 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 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#[cfg(feature = "host-decompressors")]
87struct ArMember<'a> {
88 name: &'a str,
89 data: &'a [u8],
90}
91
92#[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)?; }
115 None
116}
117
118#[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 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#[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#[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 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 #[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 #[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 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 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 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 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}