Skip to main content

provenant/parsers/debian/
control.rs

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