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 #[cfg(feature = "host-decompressors")]
60 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 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#[cfg(feature = "host-decompressors")]
95struct ArMember<'a> {
96 name: &'a str,
97 data: &'a [u8],
98}
99
100#[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)?; }
123 None
124}
125
126#[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 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#[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#[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 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 #[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 #[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 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 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 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 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}