Skip to main content

provenant/parsers/debian/
deb.rs

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
18/// Parser for Debian binary package archives (.deb files)
19pub 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        // Try to extract metadata from archive contents first
30        if let Ok(data) = extract_deb_archive(path) {
31            return vec![data];
32        }
33
34        // Fallback to filename parsing
35        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(&paragraphs[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(&copyright_content, package.name.as_deref());
342            merge_debian_copyright_into_package(package, &copyright_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 &copyright.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
419/// Parser for control files inside extracted .deb control tarballs.
420///
421/// Matches paths like `*/control.tar.gz-extract/control` and
422/// `*/control.tar.xz-extract/control` which are created by ExtractCode
423/// when extracting .deb archives.
424pub 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        // A control file inside an extracted .deb has a single paragraph
450        // (unlike debian/control which has source + binary paragraphs)
451        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            &paragraphs[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
472/// Parser for MD5 checksum files inside extracted .deb control tarballs
473pub 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}