Skip to main content

provenant/parsers/debian/
control.rs

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