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