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::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
22/// Parser for Debian binary package archives (.deb files)
23pub 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        // Try to extract metadata from archive contents first
44        if let Ok(data) = extract_deb_archive(path) {
45            return vec![data];
46        }
47
48        // Fallback to filename parsing
49        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(&paragraphs[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(&copyright_content, package.name.as_deref());
348            merge_debian_copyright_into_package(package, &copyright_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 &copyright.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
425/// Parser for control files inside extracted .deb control tarballs.
426///
427/// Matches paths like `*/control.tar.gz-extract/control` and
428/// `*/control.tar.xz-extract/control` which are created by ExtractCode
429/// when extracting .deb archives.
430pub 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        // A control file inside an extracted .deb has a single paragraph
471        // (unlike debian/control which has source + binary paragraphs)
472        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            &paragraphs[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
493/// Parser for MD5 checksum files inside extracted .deb control tarballs
494pub 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}