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