1use std::path::Path;
2
3use crate::models::{DatasourceId, PackageData, PackageType};
4use crate::parser_warn as warn;
5use crate::parsers::rfc822;
6use crate::parsers::utils::truncate_field;
7
8use super::control::build_package_from_paragraph;
9use super::copyright::parse_copyright_file;
10use super::file_list::parse_file_entries;
11use super::utils::build_debian_purl;
12use super::{
13 MAX_ARCHIVE_SIZE, MAX_COMPRESSION_RATIO, MAX_FILE_SIZE, PACKAGE_TYPE, default_package_data,
14 read_or_default,
15};
16use crate::parsers::PackageParser;
17
18pub struct DebianDebParser;
20
21impl PackageParser for DebianDebParser {
22 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
23
24 fn is_match(path: &Path) -> bool {
25 path.extension().and_then(|e| e.to_str()) == Some("deb")
26 }
27
28 fn extract_packages(path: &Path) -> Vec<PackageData> {
29 if let Ok(data) = extract_deb_archive(path) {
31 return vec![data];
32 }
33
34 let filename = match path.file_name().and_then(|n| n.to_str()) {
36 Some(f) => f,
37 None => {
38 return vec![default_package_data(DatasourceId::DebianDeb)];
39 }
40 };
41
42 vec![parse_deb_filename(filename)]
43 }
44}
45
46crate::register_parser!(
47 "Debian binary package archive (.deb)",
48 &["**/*.deb"],
49 "deb",
50 "",
51 Some("https://www.debian.org/doc/debian-policy/ch-binary.html"),
52);
53
54fn is_path_traversal(path: &std::path::Path) -> bool {
55 path.components()
56 .any(|c| matches!(c, std::path::Component::ParentDir))
57}
58
59#[derive(PartialEq)]
60enum ExtractionLimit {
61 Ok,
62 Exceeded,
63}
64
65fn check_extraction_limits(
66 total_extracted: &mut usize,
67 new_bytes: usize,
68 compressed_size: usize,
69 context: &str,
70) -> ExtractionLimit {
71 *total_extracted += new_bytes;
72 if compressed_size > 0 && *total_extracted / compressed_size > MAX_COMPRESSION_RATIO {
73 warn!("{context}: compression ratio exceeded MAX_COMPRESSION_RATIO, stopping");
74 ExtractionLimit::Exceeded
75 } else if *total_extracted > MAX_ARCHIVE_SIZE as usize {
76 warn!("{context}: cumulative extracted size exceeded MAX_ARCHIVE_SIZE, stopping");
77 ExtractionLimit::Exceeded
78 } else {
79 ExtractionLimit::Ok
80 }
81}
82
83fn extract_deb_archive(path: &Path) -> Result<PackageData, String> {
84 use flate2::read::GzDecoder;
85 use liblzma::read::XzDecoder;
86 use std::io::{Cursor, Read};
87
88 let file_metadata =
89 std::fs::metadata(path).map_err(|e| format!("Failed to stat .deb file: {}", e))?;
90 if file_metadata.len() > MAX_ARCHIVE_SIZE {
91 return Err(format!(
92 ".deb file exceeds MAX_ARCHIVE_SIZE ({} bytes)",
93 file_metadata.len()
94 ));
95 }
96 let compressed_size = file_metadata.len() as usize;
97
98 let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .deb file: {}", e))?;
99
100 let mut archive = ar::Archive::new(file);
101 let mut package: Option<PackageData> = None;
102 let mut total_extracted: usize = 0;
103
104 while let Some(entry_result) = archive.next_entry() {
105 let entry = entry_result.map_err(|e| format!("Failed to read ar entry: {}", e))?;
106
107 let entry_name_raw = entry.header().identifier();
108 let entry_name = String::from_utf8_lossy(entry_name_raw);
109 let had_replacement = entry_name_raw.iter().any(|&b| b > 127);
110 if had_replacement {
111 warn!(
112 "extract_deb_archive: non-UTF-8 bytes in entry name replaced with lossy conversion"
113 );
114 }
115 let entry_name = entry_name.trim().to_string();
116
117 if entry_name == "control.tar.gz" || entry_name.starts_with("control.tar") {
118 let entry_size = entry.header().size();
119 if entry_size > MAX_FILE_SIZE {
120 warn!(
121 "extract_deb_archive: control tar entry exceeds MAX_FILE_SIZE ({} bytes), skipping",
122 entry_size
123 );
124 continue;
125 }
126 let mut control_data = Vec::new();
127 entry
128 .take(MAX_FILE_SIZE)
129 .read_to_end(&mut control_data)
130 .map_err(|e| format!("Failed to read control.tar.gz: {}", e))?;
131
132 if check_extraction_limits(
133 &mut total_extracted,
134 control_data.len(),
135 compressed_size,
136 "extract_deb_archive",
137 ) == ExtractionLimit::Exceeded
138 {
139 break;
140 }
141
142 if entry_name.ends_with(".gz") {
143 let decoder = GzDecoder::new(Cursor::new(control_data));
144 if let Some(parsed_package) =
145 parse_control_tar_archive(decoder, &mut total_extracted, compressed_size)?
146 {
147 package = Some(parsed_package);
148 }
149 } else if entry_name.ends_with(".xz") {
150 let decoder = XzDecoder::new(Cursor::new(control_data));
151 if let Some(parsed_package) =
152 parse_control_tar_archive(decoder, &mut total_extracted, compressed_size)?
153 {
154 package = Some(parsed_package);
155 }
156 }
157 } else if entry_name.starts_with("data.tar") {
158 let entry_size = entry.header().size();
159 if entry_size > MAX_FILE_SIZE {
160 warn!(
161 "extract_deb_archive: data tar entry exceeds MAX_FILE_SIZE ({} bytes), skipping",
162 entry_size
163 );
164 continue;
165 }
166 let mut data = Vec::new();
167 entry
168 .take(MAX_FILE_SIZE)
169 .read_to_end(&mut data)
170 .map_err(|e| format!("Failed to read data archive: {}", e))?;
171
172 if check_extraction_limits(
173 &mut total_extracted,
174 data.len(),
175 compressed_size,
176 "extract_deb_archive",
177 ) == ExtractionLimit::Exceeded
178 {
179 break;
180 }
181
182 let Some(current_package) = package.as_mut() else {
183 continue;
184 };
185
186 if entry_name.ends_with(".gz") {
187 let decoder = GzDecoder::new(Cursor::new(data));
188 merge_deb_data_archive(
189 decoder,
190 current_package,
191 &mut total_extracted,
192 compressed_size,
193 )?;
194 } else if entry_name.ends_with(".xz") {
195 let decoder = XzDecoder::new(Cursor::new(data));
196 merge_deb_data_archive(
197 decoder,
198 current_package,
199 &mut total_extracted,
200 compressed_size,
201 )?;
202 }
203 }
204 }
205
206 package.ok_or_else(|| ".deb archive does not contain control.tar.* metadata".to_string())
207}
208
209fn parse_control_tar_archive<R: std::io::Read>(
210 reader: R,
211 total_extracted: &mut usize,
212 compressed_size: usize,
213) -> Result<Option<PackageData>, String> {
214 use std::io::Read;
215
216 let mut tar_archive = tar::Archive::new(reader);
217
218 for tar_entry_result in tar_archive
219 .entries()
220 .map_err(|e| format!("Failed to read tar entries: {}", e))?
221 {
222 let tar_entry = tar_entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
223
224 let tar_path = tar_entry
225 .path()
226 .map_err(|e| format!("Failed to get tar path: {}", e))?;
227
228 if is_path_traversal(&tar_path) {
229 warn!(
230 "parse_control_tar_archive: skipping tar entry with path traversal: {:?}",
231 tar_path
232 );
233 continue;
234 }
235
236 if tar_entry.size() > MAX_FILE_SIZE {
237 warn!(
238 "parse_control_tar_archive: tar entry exceeds MAX_FILE_SIZE ({} bytes), skipping",
239 tar_entry.size()
240 );
241 continue;
242 }
243
244 if tar_path.ends_with("control") {
245 let mut control_content = String::new();
246 tar_entry
247 .take(MAX_FILE_SIZE)
248 .read_to_string(&mut control_content)
249 .map_err(|e| format!("Failed to read control file: {}", e))?;
250
251 if check_extraction_limits(
252 total_extracted,
253 control_content.len(),
254 compressed_size,
255 "parse_control_tar_archive",
256 ) == ExtractionLimit::Exceeded
257 {
258 return Ok(None);
259 }
260
261 let paragraphs = rfc822::parse_rfc822_paragraphs(&control_content);
262 if paragraphs.is_empty() {
263 return Err("No paragraphs in control file".to_string());
264 }
265
266 if let Some(package) =
267 build_package_from_paragraph(¶graphs[0], None, DatasourceId::DebianDeb)
268 {
269 return Ok(Some(package));
270 }
271
272 return Err("Failed to parse control file".to_string());
273 }
274 }
275
276 Ok(None)
277}
278
279fn merge_deb_data_archive<R: std::io::Read>(
280 reader: R,
281 package: &mut PackageData,
282 total_extracted: &mut usize,
283 compressed_size: usize,
284) -> Result<(), String> {
285 use std::io::Read;
286
287 let mut tar_archive = tar::Archive::new(reader);
288
289 for tar_entry_result in tar_archive
290 .entries()
291 .map_err(|e| format!("Failed to read data tar entries: {}", e))?
292 {
293 let tar_entry =
294 tar_entry_result.map_err(|e| format!("Failed to read data tar entry: {}", e))?;
295
296 let tar_path = tar_entry
297 .path()
298 .map_err(|e| format!("Failed to get data tar path: {}", e))?;
299
300 if is_path_traversal(&tar_path) {
301 warn!(
302 "merge_deb_data_archive: skipping tar entry with path traversal: {:?}",
303 tar_path
304 );
305 continue;
306 }
307
308 if tar_entry.size() > MAX_FILE_SIZE {
309 warn!(
310 "merge_deb_data_archive: tar entry exceeds MAX_FILE_SIZE ({} bytes), skipping",
311 tar_entry.size()
312 );
313 continue;
314 }
315
316 let tar_path_str = tar_path.to_string_lossy();
317
318 if tar_path_str.ends_with(&format!(
319 "/usr/share/doc/{}/copyright",
320 package.name.as_deref().unwrap_or_default()
321 )) || tar_path_str.ends_with(&format!(
322 "usr/share/doc/{}/copyright",
323 package.name.as_deref().unwrap_or_default()
324 )) {
325 let mut copyright_content = String::new();
326 tar_entry
327 .take(MAX_FILE_SIZE)
328 .read_to_string(&mut copyright_content)
329 .map_err(|e| format!("Failed to read copyright file from data tar: {}", e))?;
330
331 if check_extraction_limits(
332 total_extracted,
333 copyright_content.len(),
334 compressed_size,
335 "merge_deb_data_archive",
336 ) == ExtractionLimit::Exceeded
337 {
338 return Ok(());
339 }
340
341 let copyright_pkg = parse_copyright_file(©right_content, package.name.as_deref());
342 merge_debian_copyright_into_package(package, ©right_pkg);
343 break;
344 }
345 }
346
347 Ok(())
348}
349
350pub(super) fn merge_debian_copyright_into_package(
351 target: &mut PackageData,
352 copyright: &PackageData,
353) {
354 if target.extracted_license_statement.is_none() {
355 target.extracted_license_statement = copyright.extracted_license_statement.clone();
356 }
357
358 if target.declared_license_expression.is_none() {
359 target.declared_license_expression = copyright.declared_license_expression.clone();
360 }
361 if target.declared_license_expression_spdx.is_none() {
362 target.declared_license_expression_spdx =
363 copyright.declared_license_expression_spdx.clone();
364 }
365 if target.license_detections.is_empty() {
366 target.license_detections = copyright.license_detections.clone();
367 }
368 if target.other_license_expression.is_none() {
369 target.other_license_expression = copyright.other_license_expression.clone();
370 }
371 if target.other_license_expression_spdx.is_none() {
372 target.other_license_expression_spdx = copyright.other_license_expression_spdx.clone();
373 }
374 if target.other_license_detections.is_empty() {
375 target.other_license_detections = copyright.other_license_detections.clone();
376 }
377
378 for party in ©right.parties {
379 if !target.parties.iter().any(|existing| existing == party) {
380 target.parties.push(party.clone());
381 }
382 }
383}
384
385fn parse_deb_filename(filename: &str) -> PackageData {
386 let without_ext = filename.trim_end_matches(".deb");
387
388 let parts: Vec<&str> = without_ext.split('_').collect();
389 if parts.len() < 2 {
390 return default_package_data(DatasourceId::DebianDeb);
391 }
392
393 let name = truncate_field(parts[0].to_string());
394 let version = truncate_field(parts[1].to_string());
395 let architecture = if parts.len() >= 3 {
396 Some(truncate_field(parts[2].to_string()))
397 } else {
398 None
399 };
400
401 let namespace = Some("debian".to_string());
402
403 PackageData {
404 datasource_id: Some(DatasourceId::DebianDeb),
405 package_type: Some(PACKAGE_TYPE),
406 namespace: namespace.clone(),
407 name: Some(name.clone()),
408 version: Some(version.clone()),
409 purl: build_debian_purl(
410 &name,
411 Some(&version),
412 namespace.as_deref(),
413 architecture.as_deref(),
414 ),
415 ..Default::default()
416 }
417}
418
419pub struct DebianControlInExtractedDebParser;
425
426impl PackageParser for DebianControlInExtractedDebParser {
427 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
428
429 fn is_match(path: &Path) -> bool {
430 path.file_name()
431 .and_then(|n| n.to_str())
432 .is_some_and(|name| name == "control")
433 && path
434 .to_str()
435 .map(|p| {
436 p.ends_with("control.tar.gz-extract/control")
437 || p.ends_with("control.tar.xz-extract/control")
438 })
439 .unwrap_or(false)
440 }
441
442 fn extract_packages(path: &Path) -> Vec<PackageData> {
443 let content = read_or_default!(
444 path,
445 "control file in extracted deb",
446 DatasourceId::DebianControlExtractedDeb
447 );
448
449 let paragraphs = rfc822::parse_rfc822_paragraphs(&content);
452 if paragraphs.is_empty() {
453 return vec![default_package_data(
454 DatasourceId::DebianControlExtractedDeb,
455 )];
456 }
457
458 if let Some(pkg) = build_package_from_paragraph(
459 ¶graphs[0],
460 None,
461 DatasourceId::DebianControlExtractedDeb,
462 ) {
463 vec![pkg]
464 } else {
465 vec![default_package_data(
466 DatasourceId::DebianControlExtractedDeb,
467 )]
468 }
469 }
470}
471
472pub struct DebianMd5sumInPackageParser;
474
475impl PackageParser for DebianMd5sumInPackageParser {
476 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
477
478 fn is_match(path: &Path) -> bool {
479 path.file_name()
480 .and_then(|n| n.to_str())
481 .is_some_and(|name| name == "md5sums")
482 && path
483 .to_str()
484 .map(|p| {
485 p.ends_with("control.tar.gz-extract/md5sums")
486 || p.ends_with("control.tar.xz-extract/md5sums")
487 })
488 .unwrap_or(false)
489 }
490
491 fn extract_packages(path: &Path) -> Vec<PackageData> {
492 let content = read_or_default!(
493 path,
494 "md5sums file",
495 DatasourceId::DebianMd5SumsInExtractedDeb
496 );
497
498 let package_name = extract_package_name_from_deb_path(path);
499
500 vec![parse_md5sums_in_package(&content, package_name.as_deref())]
501 }
502}
503
504pub(crate) fn extract_package_name_from_deb_path(path: &Path) -> Option<String> {
505 let parent = path.parent()?;
506 let grandparent = parent.parent()?;
507 let dirname = grandparent.file_name()?.to_str()?;
508 let without_extract = dirname.strip_suffix("-extract")?;
509 let without_deb = without_extract.strip_suffix(".deb")?;
510 let name = without_deb.split('_').next()?;
511
512 Some(name.to_string())
513}
514
515fn parse_md5sums_in_package(content: &str, package_name: Option<&str>) -> PackageData {
516 let file_references = parse_file_entries(content, "parse_md5sums_in_package");
517
518 if file_references.is_empty() {
519 return default_package_data(DatasourceId::DebianMd5SumsInExtractedDeb);
520 }
521
522 let namespace = Some("debian".to_string());
523 let mut package = PackageData {
524 datasource_id: Some(DatasourceId::DebianMd5SumsInExtractedDeb),
525 package_type: Some(PACKAGE_TYPE),
526 namespace: namespace.clone(),
527 name: package_name.map(|s| truncate_field(s.to_string())),
528 file_references,
529 ..Default::default()
530 };
531
532 if let Some(n) = &package.name {
533 package.purl = build_debian_purl(n, None, namespace.as_deref(), None);
534 }
535
536 package
537}
538
539crate::register_parser!(
540 "Debian control file in extracted .deb control tarball",
541 &[
542 "**/control.tar.gz-extract/control",
543 "**/control.tar.xz-extract/control"
544 ],
545 "deb",
546 "",
547 Some("https://www.debian.org/doc/debian-policy/ch-controlfields.html"),
548);
549
550crate::register_parser!(
551 "Debian MD5 checksums in extracted .deb control tarball",
552 &[
553 "**/control.tar.gz-extract/md5sums",
554 "**/control.tar.xz-extract/md5sums"
555 ],
556 "deb",
557 "",
558 Some("https://www.debian.org/doc/debian-policy/ch-controlfields.html"),
559);
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::models::DatasourceId;
565 use ar::{Builder as ArBuilder, Header as ArHeader};
566 use flate2::Compression;
567 use flate2::write::GzEncoder;
568 use liblzma::write::XzEncoder;
569 use std::io::Cursor;
570 use std::path::PathBuf;
571 use tar::{Builder as TarBuilder, Header as TarHeader};
572 use tempfile::NamedTempFile;
573
574 fn create_synthetic_deb_with_control_tar_xz() -> NamedTempFile {
575 let mut control_tar = Vec::new();
576 {
577 let encoder = XzEncoder::new(&mut control_tar, 6);
578 let mut tar_builder = TarBuilder::new(encoder);
579
580 let control_content = b"Package: synthetic\nVersion: 1.2.3\nArchitecture: amd64\nDescription: Synthetic deb\nHomepage: https://example.com\n";
581 let mut header = TarHeader::new_gnu();
582 header
583 .set_path("control")
584 .expect("control tar path should be valid");
585 header.set_size(control_content.len() as u64);
586 header.set_mode(0o644);
587 header.set_cksum();
588 tar_builder
589 .append(&header, Cursor::new(control_content))
590 .expect("control file should be appended to tar.xz");
591 tar_builder.finish().expect("control tar.xz should finish");
592 }
593
594 let deb = NamedTempFile::new().expect("temp deb file should be created");
595 {
596 let mut builder = ArBuilder::new(
597 deb.reopen()
598 .expect("temporary deb file should reopen for writing"),
599 );
600
601 let debian_binary = b"2.0\n";
602 let mut debian_binary_header =
603 ArHeader::new(b"debian-binary".to_vec(), debian_binary.len() as u64);
604 debian_binary_header.set_mode(0o100644);
605 builder
606 .append(&debian_binary_header, Cursor::new(debian_binary))
607 .expect("debian-binary entry should be appended");
608
609 let mut control_header =
610 ArHeader::new(b"control.tar.xz".to_vec(), control_tar.len() as u64);
611 control_header.set_mode(0o100644);
612 builder
613 .append(&control_header, Cursor::new(control_tar))
614 .expect("control.tar.xz entry should be appended");
615 }
616
617 deb
618 }
619
620 fn create_synthetic_deb_with_copyright() -> NamedTempFile {
621 let mut control_tar = Vec::new();
622 {
623 let encoder = GzEncoder::new(&mut control_tar, Compression::default());
624 let mut tar_builder = TarBuilder::new(encoder);
625
626 let control_content = b"Package: synthetic\nVersion: 9.9.9\nArchitecture: all\nDescription: Synthetic deb with copyright\n";
627 let mut header = TarHeader::new_gnu();
628 header
629 .set_path("control")
630 .expect("control tar path should be valid");
631 header.set_size(control_content.len() as u64);
632 header.set_mode(0o644);
633 header.set_cksum();
634 tar_builder
635 .append(&header, Cursor::new(control_content))
636 .expect("control file should be appended to tar.gz");
637 tar_builder.finish().expect("control tar.gz should finish");
638 }
639
640 let mut data_tar = Vec::new();
641 {
642 let encoder = GzEncoder::new(&mut data_tar, Compression::default());
643 let mut tar_builder = TarBuilder::new(encoder);
644
645 let copyright = b"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nFiles: *\nCopyright: 2024 Example Org\nLicense: Apache-2.0\n Licensed under the Apache License, Version 2.0.\n";
646 let mut header = TarHeader::new_gnu();
647 header
648 .set_path("./usr/share/doc/synthetic/copyright")
649 .expect("copyright path should be valid");
650 header.set_size(copyright.len() as u64);
651 header.set_mode(0o644);
652 header.set_cksum();
653 tar_builder
654 .append(&header, Cursor::new(copyright))
655 .expect("copyright file should be appended to data tar");
656 tar_builder.finish().expect("data tar.gz should finish");
657 }
658
659 let deb = NamedTempFile::new().expect("temp deb file should be created");
660 {
661 let mut builder = ArBuilder::new(
662 deb.reopen()
663 .expect("temporary deb file should reopen for writing"),
664 );
665
666 let debian_binary = b"2.0\n";
667 let mut debian_binary_header =
668 ArHeader::new(b"debian-binary".to_vec(), debian_binary.len() as u64);
669 debian_binary_header.set_mode(0o100644);
670 builder
671 .append(&debian_binary_header, Cursor::new(debian_binary))
672 .expect("debian-binary entry should be appended");
673
674 let mut control_header =
675 ArHeader::new(b"control.tar.gz".to_vec(), control_tar.len() as u64);
676 control_header.set_mode(0o100644);
677 builder
678 .append(&control_header, Cursor::new(control_tar))
679 .expect("control.tar.gz entry should be appended");
680
681 let mut data_header = ArHeader::new(b"data.tar.gz".to_vec(), data_tar.len() as u64);
682 data_header.set_mode(0o100644);
683 builder
684 .append(&data_header, Cursor::new(data_tar))
685 .expect("data.tar.gz entry should be appended");
686 }
687
688 deb
689 }
690
691 #[test]
692 fn test_deb_parser_is_match() {
693 assert!(DebianDebParser::is_match(&PathBuf::from("package.deb")));
694 assert!(DebianDebParser::is_match(&PathBuf::from(
695 "libapache2-mod-md_2.4.38-3+deb10u10_amd64.deb"
696 )));
697 assert!(!DebianDebParser::is_match(&PathBuf::from("package.tar.gz")));
698 assert!(!DebianDebParser::is_match(&PathBuf::from("control")));
699 }
700
701 #[test]
702 fn test_parse_deb_filename() {
703 let pkg = parse_deb_filename("nginx_1.18.0-1_amd64.deb");
704 assert_eq!(pkg.name, Some("nginx".to_string()));
705 assert_eq!(pkg.version, Some("1.18.0-1".to_string()));
706
707 let pkg = parse_deb_filename("invalid.deb");
708 assert!(pkg.name.is_none());
709 assert!(pkg.version.is_none());
710 }
711
712 #[test]
713 fn test_parse_deb_filename_with_arch() {
714 let pkg = parse_deb_filename("libapache2-mod-md_2.4.38-3+deb10u10_amd64.deb");
715 assert_eq!(pkg.name, Some("libapache2-mod-md".to_string()));
716 assert_eq!(pkg.version, Some("2.4.38-3+deb10u10".to_string()));
717 assert_eq!(pkg.namespace, Some("debian".to_string()));
718 assert_eq!(
719 pkg.purl,
720 Some("pkg:deb/debian/libapache2-mod-md@2.4.38-3%2Bdeb10u10?arch=amd64".to_string())
721 );
722 assert_eq!(pkg.datasource_id, Some(DatasourceId::DebianDeb));
723 }
724
725 #[test]
726 fn test_parse_deb_filename_without_arch() {
727 let pkg = parse_deb_filename("package_1.0-1_all.deb");
728 assert_eq!(pkg.name, Some("package".to_string()));
729 assert_eq!(pkg.version, Some("1.0-1".to_string()));
730 assert!(pkg.purl.as_ref().unwrap().contains("arch=all"));
731 }
732
733 #[test]
734 fn test_extract_deb_archive() {
735 let test_path = PathBuf::from("testdata/debian/deb/adduser_3.112ubuntu1_all.deb");
736 if !test_path.exists() {
737 return;
738 }
739
740 let pkg = DebianDebParser::extract_first_package(&test_path);
741
742 assert_eq!(pkg.name, Some("adduser".to_string()));
743 assert_eq!(pkg.version, Some("3.112ubuntu1".to_string()));
744 assert_eq!(pkg.namespace, Some("ubuntu".to_string()));
745 assert!(pkg.description.is_some());
746 assert!(!pkg.parties.is_empty());
747
748 assert!(pkg.purl.as_ref().unwrap().contains("adduser"));
749 assert!(pkg.purl.as_ref().unwrap().contains("3.112ubuntu1"));
750 }
751
752 #[test]
753 fn test_deb_parser_xz_control() {
754 let deb = create_synthetic_deb_with_control_tar_xz();
755
756 let pkg = DebianDebParser::extract_first_package(deb.path());
757
758 assert_eq!(pkg.name, Some("synthetic".to_string()));
759 assert_eq!(pkg.version, Some("1.2.3".to_string()));
760 assert_eq!(pkg.description, Some("Synthetic deb".to_string()));
761 assert_eq!(pkg.homepage_url, Some("https://example.com".to_string()));
762 }
763
764 #[test]
765 fn test_deb_parser_with_copyright() {
766 let deb = create_synthetic_deb_with_copyright();
767
768 let pkg = DebianDebParser::extract_first_package(deb.path());
769
770 assert_eq!(pkg.name, Some("synthetic".to_string()));
771 assert_eq!(
772 pkg.extracted_license_statement,
773 Some("Apache-2.0".to_string())
774 );
775 assert!(pkg.parties.iter().any(|party| {
776 party.role.as_deref() == Some("copyright-holder")
777 && party.name.as_deref() == Some("Example Org")
778 }));
779 }
780
781 #[test]
782 fn test_parse_deb_filename_simple() {
783 let pkg = parse_deb_filename("adduser_3.112ubuntu1_all.deb");
784 assert_eq!(pkg.name, Some("adduser".to_string()));
785 assert_eq!(pkg.version, Some("3.112ubuntu1".to_string()));
786 assert_eq!(pkg.namespace, Some("debian".to_string()));
787 }
788
789 #[test]
790 fn test_parse_deb_filename_invalid() {
791 let pkg = parse_deb_filename("invalid.deb");
792 assert!(pkg.name.is_none());
793 assert!(pkg.version.is_none());
794 }
795}