Skip to main content

provenant/parsers/debian/
control.rs

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