Skip to main content

provenant/parsers/debian/
control.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::models::{DatasourceId, PackageData, PackageType, Party};
8use crate::parser_warn as warn;
9use crate::parsers::rfc822::{self, Rfc822Metadata};
10use crate::parsers::utils::{MAX_ITERATION_COUNT, split_name_email, truncate_field};
11
12use super::super::metadata::ParserMetadata;
13use super::utils::{
14    build_debian_purl, detect_namespace, make_party, parse_all_dependencies, parse_source_field,
15};
16use super::{PACKAGE_TYPE, default_package_data, read_or_default};
17use crate::parsers::PackageParser;
18
19// ---------------------------------------------------------------------------
20// DebianControlParser: debian/control files (source + binary paragraphs)
21// ---------------------------------------------------------------------------
22
23pub struct DebianControlParser;
24
25impl PackageParser for DebianControlParser {
26    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
27
28    fn metadata() -> Vec<ParserMetadata> {
29        vec![ParserMetadata {
30            description: "Debian source package control file (debian/control)",
31            file_patterns: &["**/debian/control"],
32            package_type: "deb",
33            primary_language: "",
34            documentation_url: Some(
35                "https://www.debian.org/doc/debian-policy/ch-controlfields.html",
36            ),
37        }]
38    }
39
40    fn is_match(path: &Path) -> bool {
41        if let Some(name) = path.file_name()
42            && name == "control"
43            && let Some(parent) = path.parent()
44            && let Some(parent_name) = parent.file_name()
45        {
46            return parent_name == "debian";
47        }
48        false
49    }
50
51    fn extract_packages(path: &Path) -> Vec<PackageData> {
52        let content = read_or_default!(path, "debian/control", DatasourceId::DebianControlInSource);
53
54        let packages = parse_debian_control(&content);
55        if packages.is_empty() {
56            vec![default_package_data(DatasourceId::DebianControlInSource)]
57        } else {
58            packages
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// DebianInstalledParser: /var/lib/dpkg/status
65// ---------------------------------------------------------------------------
66
67pub struct DebianInstalledParser;
68
69impl PackageParser for DebianInstalledParser {
70    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
71
72    fn metadata() -> Vec<ParserMetadata> {
73        vec![ParserMetadata {
74            description: "Debian installed package database (dpkg status)",
75            file_patterns: &["**/var/lib/dpkg/status"],
76            package_type: "deb",
77            primary_language: "",
78            documentation_url: Some(
79                "https://www.debian.org/doc/debian-policy/ch-controlfields.html",
80            ),
81        }]
82    }
83
84    fn is_match(path: &Path) -> bool {
85        let path_str = path.to_string_lossy();
86        path_str.ends_with("var/lib/dpkg/status")
87    }
88
89    fn extract_packages(path: &Path) -> Vec<PackageData> {
90        let content = read_or_default!(path, "dpkg/status", DatasourceId::DebianInstalledStatusDb);
91
92        let packages = parse_dpkg_status(&content);
93        if packages.is_empty() {
94            vec![default_package_data(DatasourceId::DebianInstalledStatusDb)]
95        } else {
96            packages
97        }
98    }
99}
100
101pub struct DebianDistrolessInstalledParser;
102
103impl PackageParser for DebianDistrolessInstalledParser {
104    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
105
106    fn metadata() -> Vec<ParserMetadata> {
107        vec![ParserMetadata {
108            description: "Debian distroless package database (status.d)",
109            file_patterns: &["**/var/lib/dpkg/status.d/*"],
110            package_type: "deb",
111            primary_language: "",
112            documentation_url: Some(
113                "https://www.debian.org/doc/debian-policy/ch-controlfields.html",
114            ),
115        }]
116    }
117
118    fn is_match(path: &Path) -> bool {
119        let path_str = path.to_string_lossy();
120        path_str.contains("var/lib/dpkg/status.d/")
121    }
122
123    fn extract_packages(path: &Path) -> Vec<PackageData> {
124        let content = read_or_default!(
125            path,
126            "distroless status file",
127            DatasourceId::DebianDistrolessInstalledDb
128        );
129
130        vec![parse_distroless_status(&content)]
131    }
132}
133
134fn parse_distroless_status(content: &str) -> PackageData {
135    let paragraphs = rfc822::parse_rfc822_paragraphs(content);
136
137    if paragraphs.is_empty() {
138        return default_package_data(DatasourceId::DebianDistrolessInstalledDb);
139    }
140
141    build_package_from_paragraph(
142        &paragraphs[0],
143        None,
144        DatasourceId::DebianDistrolessInstalledDb,
145    )
146    .unwrap_or_else(|| default_package_data(DatasourceId::DebianDistrolessInstalledDb))
147}
148
149// ---------------------------------------------------------------------------
150// Parsing logic
151// ---------------------------------------------------------------------------
152
153/// Parses a debian/control file into PackageData entries.
154///
155/// A debian/control file has a Source paragraph followed by one or more Binary
156/// paragraphs. Source-level metadata (maintainer, homepage, VCS URLs) is merged
157/// into each binary package.
158fn parse_debian_control(content: &str) -> Vec<PackageData> {
159    let paragraphs = rfc822::parse_rfc822_paragraphs(content);
160    if paragraphs.is_empty() {
161        return Vec::new();
162    }
163
164    let has_source = rfc822::get_header_first(&paragraphs[0].headers, "source").is_some();
165
166    let (source_paragraph, binary_start) = if has_source {
167        (Some(&paragraphs[0]), 1)
168    } else {
169        (None, 0)
170    };
171
172    let source_meta = source_paragraph.map(extract_source_meta);
173
174    let mut packages = Vec::new();
175    let mut count = 0usize;
176
177    for para in &paragraphs[binary_start..] {
178        count += 1;
179        if count > MAX_ITERATION_COUNT {
180            warn!("parse_debian_control: exceeded MAX_ITERATION_COUNT paragraphs, stopping");
181            break;
182        }
183        if let Some(pkg) = build_package_from_paragraph(
184            para,
185            source_meta.as_ref(),
186            DatasourceId::DebianControlInSource,
187        ) {
188            packages.push(pkg);
189        }
190    }
191
192    if packages.is_empty()
193        && let Some(source_para) = source_paragraph
194        && let Some(pkg) = build_package_from_source_paragraph(source_para)
195    {
196        packages.push(pkg);
197    }
198
199    packages
200}
201
202/// Parses a dpkg/status file into PackageData entries.
203///
204/// Each paragraph represents an installed package. Only packages with
205/// `Status: install ok installed` are included.
206fn parse_dpkg_status(content: &str) -> Vec<PackageData> {
207    let paragraphs = rfc822::parse_rfc822_paragraphs(content);
208    let mut packages = Vec::new();
209    let mut count = 0usize;
210
211    for para in &paragraphs {
212        count += 1;
213        if count > MAX_ITERATION_COUNT {
214            warn!("parse_dpkg_status: exceeded MAX_ITERATION_COUNT paragraphs, stopping");
215            break;
216        }
217        let status = rfc822::get_header_first(&para.headers, "status");
218        if status.as_deref() != Some("install ok installed") {
219            continue;
220        }
221
222        if let Some(pkg) =
223            build_package_from_paragraph(para, None, DatasourceId::DebianInstalledStatusDb)
224        {
225            packages.push(pkg);
226        }
227    }
228
229    packages
230}
231
232// ---------------------------------------------------------------------------
233// Source paragraph metadata (shared across binary packages)
234// ---------------------------------------------------------------------------
235
236pub(super) struct SourceMeta {
237    parties: Vec<Party>,
238    homepage_url: Option<String>,
239    vcs_url: Option<String>,
240    code_view_url: Option<String>,
241    bug_tracking_url: Option<String>,
242}
243
244fn extract_source_meta(paragraph: &Rfc822Metadata) -> SourceMeta {
245    let mut parties = Vec::new();
246
247    // Maintainer
248    if let Some(maintainer) = rfc822::get_header_first(&paragraph.headers, "maintainer") {
249        let (name, email) = split_name_email(&maintainer);
250        parties.push(make_party(Some("person"), "maintainer", name, email));
251    }
252
253    // Original-Maintainer
254    if let Some(orig_maintainer) =
255        rfc822::get_header_first(&paragraph.headers, "original-maintainer")
256    {
257        let (name, email) = split_name_email(&orig_maintainer);
258        parties.push(make_party(Some("person"), "maintainer", name, email));
259    }
260
261    // Uploaders (comma-separated)
262    if let Some(uploaders_str) = rfc822::get_header_first(&paragraph.headers, "uploaders") {
263        for uploader in uploaders_str.split(',') {
264            let trimmed = uploader.trim();
265            if !trimmed.is_empty() {
266                let (name, email) = split_name_email(trimmed);
267                parties.push(make_party(Some("person"), "uploader", name, email));
268            }
269        }
270    }
271
272    let homepage_url = rfc822::get_header_first(&paragraph.headers, "homepage").map(truncate_field);
273
274    let vcs_url = rfc822::get_header_first(&paragraph.headers, "vcs-git")
275        .map(|url| truncate_field(url.split_whitespace().next().unwrap_or(&url).to_string()));
276
277    let code_view_url =
278        rfc822::get_header_first(&paragraph.headers, "vcs-browser").map(truncate_field);
279
280    let bug_tracking_url = rfc822::get_header_first(&paragraph.headers, "bugs").map(truncate_field);
281
282    SourceMeta {
283        parties,
284        homepage_url,
285        vcs_url,
286        code_view_url,
287        bug_tracking_url,
288    }
289}
290
291// ---------------------------------------------------------------------------
292// Package building
293// ---------------------------------------------------------------------------
294
295pub(super) fn build_package_from_paragraph(
296    paragraph: &Rfc822Metadata,
297    source_meta: Option<&SourceMeta>,
298    datasource_id: DatasourceId,
299) -> Option<PackageData> {
300    let name = rfc822::get_header_first(&paragraph.headers, "package").map(truncate_field)?;
301    let version = rfc822::get_header_first(&paragraph.headers, "version").map(truncate_field);
302    let architecture =
303        rfc822::get_header_first(&paragraph.headers, "architecture").map(truncate_field);
304    let description =
305        rfc822::get_header_first(&paragraph.headers, "description").map(truncate_field);
306    let maintainer_str = rfc822::get_header_first(&paragraph.headers, "maintainer");
307    let homepage = rfc822::get_header_first(&paragraph.headers, "homepage").map(truncate_field);
308    let source_field = rfc822::get_header_first(&paragraph.headers, "source");
309    let section = rfc822::get_header_first(&paragraph.headers, "section");
310    let installed_size = rfc822::get_header_first(&paragraph.headers, "installed-size");
311    let multi_arch = rfc822::get_header_first(&paragraph.headers, "multi-arch");
312
313    let namespace = detect_namespace(version.as_deref(), maintainer_str.as_deref());
314
315    // Build parties: use source_meta parties if available, otherwise parse from paragraph
316    let parties = if let Some(meta) = source_meta {
317        meta.parties.clone()
318    } else {
319        let mut p = Vec::new();
320        if let Some(m) = &maintainer_str {
321            let (n, e) = split_name_email(m);
322            p.push(make_party(Some("person"), "maintainer", n, e));
323        }
324        p
325    };
326
327    // Resolve homepage: paragraph's own, or from source metadata
328    let homepage_url = homepage.or_else(|| source_meta.and_then(|m| m.homepage_url.clone()));
329    let vcs_url = source_meta.and_then(|m| m.vcs_url.clone());
330    let code_view_url = source_meta.and_then(|m| m.code_view_url.clone());
331    let bug_tracking_url = source_meta.and_then(|m| m.bug_tracking_url.clone());
332
333    // Build PURL
334    let purl = build_debian_purl(
335        &name,
336        version.as_deref(),
337        namespace.as_deref(),
338        architecture.as_deref(),
339    );
340
341    // Parse dependencies from all dependency fields
342    let dependencies = parse_all_dependencies(&paragraph.headers, namespace.as_deref());
343
344    // Keywords from section
345    let keywords = section.into_iter().collect();
346
347    // Source packages
348    let source_packages = parse_source_field(source_field.as_deref(), namespace.as_deref());
349
350    // Extra data
351    let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
352    if let Some(ma) = &multi_arch
353        && !ma.is_empty()
354    {
355        extra_data.insert(
356            "multi_arch".to_string(),
357            serde_json::Value::String(ma.clone()),
358        );
359    }
360    if let Some(size_str) = &installed_size
361        && let Ok(size) = size_str.parse::<u64>()
362    {
363        extra_data.insert(
364            "installed_size".to_string(),
365            serde_json::Value::Number(serde_json::Number::from(size)),
366        );
367    }
368
369    // Qualifiers for architecture
370    let qualifiers = architecture.as_ref().map(|arch| {
371        let mut q = HashMap::new();
372        q.insert("arch".to_string(), arch.clone());
373        q
374    });
375
376    Some(PackageData {
377        package_type: Some(PACKAGE_TYPE),
378        namespace: namespace.clone(),
379        name: Some(name),
380        version,
381        qualifiers,
382        description,
383        parties,
384        keywords,
385        homepage_url,
386        bug_tracking_url,
387        code_view_url,
388        vcs_url,
389        source_packages,
390        file_references: Vec::new(),
391        extra_data: if extra_data.is_empty() {
392            None
393        } else {
394            Some(extra_data)
395        },
396        dependencies,
397        datasource_id: Some(datasource_id),
398        purl,
399        ..Default::default()
400    })
401}
402
403fn build_package_from_source_paragraph(paragraph: &Rfc822Metadata) -> Option<PackageData> {
404    let name = rfc822::get_header_first(&paragraph.headers, "source").map(truncate_field)?;
405    let version = rfc822::get_header_first(&paragraph.headers, "version").map(truncate_field);
406    let maintainer_str = rfc822::get_header_first(&paragraph.headers, "maintainer");
407
408    let namespace = detect_namespace(version.as_deref(), maintainer_str.as_deref());
409    let source_meta = extract_source_meta(paragraph);
410
411    let purl = build_debian_purl(&name, version.as_deref(), namespace.as_deref(), None);
412    let dependencies = parse_all_dependencies(&paragraph.headers, namespace.as_deref());
413
414    let section = rfc822::get_header_first(&paragraph.headers, "section");
415    let keywords = section.into_iter().collect();
416
417    Some(PackageData {
418        package_type: Some(PACKAGE_TYPE),
419        namespace: namespace.clone(),
420        name: Some(name),
421        version,
422        parties: source_meta.parties,
423        keywords,
424        homepage_url: source_meta.homepage_url,
425        bug_tracking_url: source_meta.bug_tracking_url,
426        code_view_url: source_meta.code_view_url,
427        vcs_url: source_meta.vcs_url,
428        dependencies,
429        datasource_id: Some(DatasourceId::DebianControlInSource),
430        purl,
431        ..Default::default()
432    })
433}
434
435// ---------------------------------------------------------------------------
436// Parser registration
437// ---------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::models::DatasourceId;
443    use crate::models::PackageType;
444    use std::path::Path;
445    use std::path::PathBuf;
446
447    #[test]
448    fn test_parse_debian_control_source_and_binary() {
449        let content = "\
450Source: curl
451Section: web
452Priority: optional
453Maintainer: Alessandro Ghedini <ghedo@debian.org>
454Homepage: https://curl.se/
455Vcs-Browser: https://salsa.debian.org/debian/curl
456Vcs-Git: https://salsa.debian.org/debian/curl.git
457Build-Depends: debhelper (>= 12), libssl-dev
458
459Package: curl
460Architecture: amd64
461Depends: libc6 (>= 2.17), libcurl4 (= ${binary:Version})
462Description: command line tool for transferring data with URL syntax";
463
464        let packages = parse_debian_control(content);
465        assert_eq!(packages.len(), 1);
466
467        let pkg = &packages[0];
468        assert_eq!(pkg.name, Some("curl".to_string()));
469        assert_eq!(pkg.package_type, Some(PackageType::Deb));
470        assert_eq!(pkg.homepage_url, Some("https://curl.se/".to_string()));
471        assert_eq!(
472            pkg.vcs_url,
473            Some("https://salsa.debian.org/debian/curl.git".to_string())
474        );
475        assert_eq!(
476            pkg.code_view_url,
477            Some("https://salsa.debian.org/debian/curl".to_string())
478        );
479
480        assert_eq!(pkg.parties.len(), 1);
481        assert_eq!(pkg.parties[0].role, Some("maintainer".to_string()));
482        assert_eq!(pkg.parties[0].name, Some("Alessandro Ghedini".to_string()));
483        assert_eq!(pkg.parties[0].email, Some("ghedo@debian.org".to_string()));
484
485        assert!(!pkg.dependencies.is_empty());
486    }
487
488    #[test]
489    fn test_parse_debian_control_multiple_binary() {
490        let content = "\
491Source: gzip
492Maintainer: Debian Developer <dev@debian.org>
493
494Package: gzip
495Architecture: any
496Depends: libc6 (>= 2.17)
497Description: GNU file compression
498
499Package: gzip-win32
500Architecture: all
501Description: gzip for Windows";
502
503        let packages = parse_debian_control(content);
504        assert_eq!(packages.len(), 2);
505        assert_eq!(packages[0].name, Some("gzip".to_string()));
506        assert_eq!(packages[1].name, Some("gzip-win32".to_string()));
507
508        assert_eq!(packages[0].parties.len(), 1);
509        assert_eq!(packages[1].parties.len(), 1);
510    }
511
512    #[test]
513    fn test_parse_debian_control_source_only() {
514        let content = "\
515Source: my-package
516Maintainer: Test User <test@debian.org>
517Build-Depends: debhelper (>= 13)";
518
519        let packages = parse_debian_control(content);
520        assert_eq!(packages.len(), 1);
521        assert_eq!(packages[0].name, Some("my-package".to_string()));
522        assert!(!packages[0].dependencies.is_empty());
523        assert_eq!(
524            packages[0].dependencies[0].scope,
525            Some("build-depends".to_string())
526        );
527    }
528
529    #[test]
530    fn test_parse_debian_control_with_uploaders() {
531        let content = "\
532Source: example
533Maintainer: Main Dev <main@debian.org>
534Uploaders: Alice <alice@example.com>, Bob <bob@example.com>
535
536Package: example
537Architecture: any
538Description: test package";
539
540        let packages = parse_debian_control(content);
541        assert_eq!(packages.len(), 1);
542        assert_eq!(packages[0].parties.len(), 3);
543        assert_eq!(packages[0].parties[0].role, Some("maintainer".to_string()));
544        assert_eq!(packages[0].parties[1].role, Some("uploader".to_string()));
545        assert_eq!(packages[0].parties[2].role, Some("uploader".to_string()));
546    }
547
548    #[test]
549    fn test_parse_debian_control_vcs_git_with_branch() {
550        let content = "\
551Source: example
552Maintainer: Dev <dev@debian.org>
553Vcs-Git: https://salsa.debian.org/example.git -b main
554
555Package: example
556Architecture: any
557Description: test";
558
559        let packages = parse_debian_control(content);
560        assert_eq!(packages.len(), 1);
561        assert_eq!(
562            packages[0].vcs_url,
563            Some("https://salsa.debian.org/example.git".to_string())
564        );
565    }
566
567    #[test]
568    fn test_parse_debian_control_multi_arch() {
569        let content = "\
570Source: example
571Maintainer: Dev <dev@debian.org>
572
573Package: libexample
574Architecture: any
575Multi-Arch: same
576Description: shared library";
577
578        let packages = parse_debian_control(content);
579        assert_eq!(packages.len(), 1);
580        let extra = packages[0].extra_data.as_ref().unwrap();
581        assert_eq!(
582            extra.get("multi_arch"),
583            Some(&serde_json::Value::String("same".to_string()))
584        );
585    }
586
587    #[test]
588    fn test_parse_dpkg_status_basic() {
589        let content = "\
590Package: base-files
591Status: install ok installed
592Priority: required
593Section: admin
594Installed-Size: 391
595Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
596Architecture: amd64
597Version: 11ubuntu5.6
598Description: Debian base system miscellaneous files
599Homepage: https://tracker.debian.org/pkg/base-files
600
601Package: not-installed
602Status: deinstall ok config-files
603Architecture: amd64
604Version: 1.0
605Description: This should be skipped";
606
607        let packages = parse_dpkg_status(content);
608        assert_eq!(packages.len(), 1);
609
610        let pkg = &packages[0];
611        assert_eq!(pkg.name, Some("base-files".to_string()));
612        assert_eq!(pkg.version, Some("11ubuntu5.6".to_string()));
613        assert_eq!(pkg.namespace, Some("ubuntu".to_string()));
614        assert_eq!(
615            pkg.datasource_id,
616            Some(DatasourceId::DebianInstalledStatusDb)
617        );
618
619        let extra = pkg.extra_data.as_ref().unwrap();
620        assert_eq!(
621            extra.get("installed_size"),
622            Some(&serde_json::Value::Number(serde_json::Number::from(391)))
623        );
624    }
625
626    #[test]
627    fn test_parse_dpkg_status_multiple_installed() {
628        let content = "\
629Package: libc6
630Status: install ok installed
631Architecture: amd64
632Version: 2.31-13+deb11u5
633Maintainer: GNU Libc Maintainers <debian-glibc@lists.debian.org>
634Description: GNU C Library
635
636Package: zlib1g
637Status: install ok installed
638Architecture: amd64
639Version: 1:1.2.11.dfsg-2+deb11u2
640Maintainer: Mark Brown <broonie@debian.org>
641Description: compression library";
642
643        let packages = parse_dpkg_status(content);
644        assert_eq!(packages.len(), 2);
645        assert_eq!(packages[0].name, Some("libc6".to_string()));
646        assert_eq!(packages[1].name, Some("zlib1g".to_string()));
647    }
648
649    #[test]
650    fn test_parse_dpkg_status_with_dependencies() {
651        let content = "\
652Package: curl
653Status: install ok installed
654Architecture: amd64
655Version: 7.74.0-1.3+deb11u7
656Maintainer: Alessandro Ghedini <ghedo@debian.org>
657Depends: libc6 (>= 2.17), libcurl4 (= 7.74.0-1.3+deb11u7)
658Recommends: ca-certificates
659Description: command line tool for transferring data with URL syntax";
660
661        let packages = parse_dpkg_status(content);
662        assert_eq!(packages.len(), 1);
663
664        let deps = &packages[0].dependencies;
665        assert_eq!(deps.len(), 3);
666
667        assert_eq!(deps[0].purl, Some("pkg:deb/debian/libc6".to_string()));
668        assert_eq!(deps[0].scope, Some("depends".to_string()));
669        assert_eq!(deps[0].extracted_requirement, Some(">= 2.17".to_string()));
670
671        assert_eq!(
672            deps[2].purl,
673            Some("pkg:deb/debian/ca-certificates".to_string())
674        );
675        assert_eq!(deps[2].scope, Some("recommends".to_string()));
676        assert_eq!(deps[2].is_optional, Some(true));
677    }
678
679    #[test]
680    fn test_parse_dpkg_status_with_source() {
681        let content = "\
682Package: libncurses6
683Status: install ok installed
684Architecture: amd64
685Source: ncurses (6.2+20201114-2+deb11u1)
686Version: 6.2+20201114-2+deb11u1
687Maintainer: Craig Small <csmall@debian.org>
688Description: shared libraries for terminal handling";
689
690        let packages = parse_dpkg_status(content);
691        assert_eq!(packages.len(), 1);
692        assert!(!packages[0].source_packages.is_empty());
693        assert!(packages[0].source_packages[0].contains("ncurses"));
694    }
695
696    #[test]
697    fn test_parse_dpkg_status_filters_not_installed() {
698        let content = "\
699Package: installed-pkg
700Status: install ok installed
701Version: 1.0
702Architecture: amd64
703Description: installed
704
705Package: half-installed
706Status: install ok half-installed
707Version: 2.0
708Architecture: amd64
709Description: half installed
710
711Package: deinstall-pkg
712Status: deinstall ok config-files
713Version: 3.0
714Architecture: amd64
715Description: deinstalled
716
717Package: purge-pkg
718Status: purge ok not-installed
719Version: 4.0
720Architecture: amd64
721Description: purged";
722
723        let packages = parse_dpkg_status(content);
724        assert_eq!(packages.len(), 1);
725        assert_eq!(packages[0].name, Some("installed-pkg".to_string()));
726    }
727
728    #[test]
729    fn test_parse_dpkg_status_empty() {
730        let packages = parse_dpkg_status("");
731        assert!(packages.is_empty());
732    }
733
734    #[test]
735    fn test_debian_control_is_match() {
736        assert!(DebianControlParser::is_match(Path::new(
737            "/path/to/debian/control"
738        )));
739        assert!(DebianControlParser::is_match(Path::new("debian/control")));
740        assert!(!DebianControlParser::is_match(Path::new(
741            "/path/to/control"
742        )));
743        assert!(!DebianControlParser::is_match(Path::new(
744            "/path/to/debian/changelog"
745        )));
746    }
747
748    #[test]
749    fn test_debian_installed_is_match() {
750        assert!(DebianInstalledParser::is_match(Path::new(
751            "/var/lib/dpkg/status"
752        )));
753        assert!(DebianInstalledParser::is_match(Path::new(
754            "some/root/var/lib/dpkg/status"
755        )));
756        assert!(!DebianInstalledParser::is_match(Path::new(
757            "/var/lib/dpkg/status.d/something"
758        )));
759        assert!(!DebianInstalledParser::is_match(Path::new(
760            "/var/lib/dpkg/available"
761        )));
762    }
763
764    #[test]
765    fn test_parse_debian_control_empty_input() {
766        let packages = parse_debian_control("");
767        assert!(packages.is_empty());
768    }
769
770    #[test]
771    fn test_parse_debian_control_malformed_input() {
772        let content = "this is not a valid control file\nwith random text";
773        let packages = parse_debian_control(content);
774        assert!(packages.is_empty());
775    }
776
777    #[test]
778    fn test_distroless_parser() {
779        let test_file = PathBuf::from("testdata/debian/var/lib/dpkg/status.d/base-files");
780
781        assert!(DebianDistrolessInstalledParser::is_match(&test_file));
782
783        if !test_file.exists() {
784            eprintln!("Warning: Test file not found, skipping test");
785            return;
786        }
787
788        let pkg = DebianDistrolessInstalledParser::extract_first_package(&test_file);
789
790        assert_eq!(pkg.package_type, Some(PackageType::Deb));
791        assert_eq!(
792            pkg.datasource_id,
793            Some(DatasourceId::DebianDistrolessInstalledDb)
794        );
795        assert_eq!(pkg.name, Some("base-files".to_string()));
796        assert_eq!(pkg.version, Some("11.1+deb11u8".to_string()));
797        assert_eq!(pkg.namespace, Some("debian".to_string()));
798        assert!(pkg.purl.is_some());
799        assert!(
800            pkg.purl
801                .as_ref()
802                .unwrap()
803                .contains("pkg:deb/debian/base-files")
804        );
805    }
806}