Skip to main content

provenant/parsers/debian/
deb.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
21/// Parser for Debian binary package archives (.deb files)
22pub 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        // Try to extract metadata from archive contents first
33        if let Ok(data) = extract_deb_archive(path) {
34            return vec![data];
35        }
36
37        // Fallback to filename parsing
38        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(&paragraphs[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(&copyright_content, package.name.as_deref());
345            merge_debian_copyright_into_package(package, &copyright_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 &copyright.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
422/// Parser for control files inside extracted .deb control tarballs.
423///
424/// Matches paths like `*/control.tar.gz-extract/control` and
425/// `*/control.tar.xz-extract/control` which are created by ExtractCode
426/// when extracting .deb archives.
427pub 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        // A control file inside an extracted .deb has a single paragraph
453        // (unlike debian/control which has source + binary paragraphs)
454        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            &paragraphs[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
475/// Parser for MD5 checksum files inside extracted .deb control tarballs
476pub 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}