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