Skip to main content

provenant/parsers/debian/
dsc.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;
7use crate::parsers::utils::{
8    MAX_ITERATION_COUNT, read_file_to_string, split_name_email, truncate_field,
9};
10
11use super::utils::{build_debian_purl, parse_dependency_field};
12use super::{PACKAGE_TYPE, default_package_data};
13use crate::parsers::PackageParser;
14
15/// Parser for Debian Source Control (.dsc) files
16pub struct DebianDscParser;
17
18impl PackageParser for DebianDscParser {
19    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
20
21    fn is_match(path: &Path) -> bool {
22        path.extension().and_then(|e| e.to_str()) == Some("dsc")
23    }
24
25    fn extract_packages(path: &Path) -> Vec<PackageData> {
26        let content = match read_file_to_string(path, None) {
27            Ok(c) => c,
28            Err(e) => {
29                warn!("Failed to read .dsc file {:?}: {}", path, e);
30                return vec![default_package_data(DatasourceId::DebianSourceControlDsc)];
31            }
32        };
33
34        vec![parse_dsc_content(&content)]
35    }
36}
37
38crate::register_parser!(
39    "Debian source control file (.dsc)",
40    &["**/*.dsc"],
41    "deb",
42    "",
43    Some("https://www.debian.org/doc/debian-policy/ch-controlfields.html"),
44);
45
46fn strip_pgp_signature(content: &str) -> String {
47    let mut result = String::new();
48    let mut in_pgp_block = false;
49    let mut in_signature = false;
50    let mut count = 0usize;
51
52    for line in content.lines() {
53        count += 1;
54        if count > MAX_ITERATION_COUNT {
55            warn!("strip_pgp_signature: exceeded MAX_ITERATION_COUNT lines, stopping");
56            break;
57        }
58        if line.starts_with("-----BEGIN PGP SIGNED MESSAGE-----") {
59            in_pgp_block = true;
60            continue;
61        }
62        if line.starts_with("-----BEGIN PGP SIGNATURE-----") {
63            in_signature = true;
64            continue;
65        }
66        if line.starts_with("-----END PGP SIGNATURE-----") {
67            in_signature = false;
68            continue;
69        }
70        if in_pgp_block && line.starts_with("Hash:") {
71            continue;
72        }
73        if in_pgp_block && line.is_empty() && result.is_empty() {
74            in_pgp_block = false;
75            continue;
76        }
77        if !in_signature {
78            result.push_str(line);
79            result.push('\n');
80        }
81    }
82
83    result
84}
85
86fn parse_dsc_content(content: &str) -> PackageData {
87    let clean_content = strip_pgp_signature(content);
88    let metadata = rfc822::parse_rfc822_content(&clean_content);
89    let headers = &metadata.headers;
90
91    let name = rfc822::get_header_first(headers, "source").map(truncate_field);
92    let version = rfc822::get_header_first(headers, "version").map(truncate_field);
93    let architecture = rfc822::get_header_first(headers, "architecture").map(truncate_field);
94    let namespace = Some("debian".to_string());
95
96    let mut package = PackageData {
97        datasource_id: Some(DatasourceId::DebianSourceControlDsc),
98        package_type: Some(PACKAGE_TYPE),
99        namespace: namespace.clone(),
100        name: name.clone(),
101        version: version.clone(),
102        description: rfc822::get_header_first(headers, "description").map(truncate_field),
103        homepage_url: rfc822::get_header_first(headers, "homepage").map(truncate_field),
104        vcs_url: rfc822::get_header_first(headers, "vcs-git").map(truncate_field),
105        code_view_url: rfc822::get_header_first(headers, "vcs-browser").map(truncate_field),
106        ..Default::default()
107    };
108
109    // Build PURL with architecture qualifier
110    if let (Some(n), Some(v)) = (&name, &version) {
111        package.purl = build_debian_purl(n, Some(v), namespace.as_deref(), architecture.as_deref());
112    }
113
114    // Set source_packages to point to the source itself (without version)
115    if let Some(n) = &name
116        && let Some(source_purl) = build_debian_purl(n, None, namespace.as_deref(), None)
117    {
118        package.source_packages.push(source_purl);
119    }
120
121    if let Some(maintainer) = rfc822::get_header_first(headers, "maintainer") {
122        let (name_opt, email_opt) = split_name_email(&maintainer);
123        package.parties.push(Party {
124            r#type: None,
125            role: Some("maintainer".to_string()),
126            name: name_opt,
127            email: email_opt,
128            url: None,
129            organization: None,
130            organization_url: None,
131            timezone: None,
132        });
133    }
134
135    if let Some(uploaders_str) = rfc822::get_header_first(headers, "uploaders") {
136        for uploader in uploaders_str.split(',') {
137            let uploader = uploader.trim();
138            if uploader.is_empty() {
139                continue;
140            }
141            let (name_opt, email_opt) = split_name_email(uploader);
142            package.parties.push(Party {
143                r#type: None,
144                role: Some("uploader".to_string()),
145                name: name_opt,
146                email: email_opt,
147                url: None,
148                organization: None,
149                organization_url: None,
150                timezone: None,
151            });
152        }
153    }
154
155    // Parse Build-Depends
156    if let Some(build_deps) = rfc822::get_header_first(headers, "build-depends") {
157        package.dependencies.extend(parse_dependency_field(
158            &build_deps,
159            "build",
160            false,
161            false,
162            namespace.as_deref(),
163        ));
164    }
165
166    // Store Standards-Version in extra_data
167    if let Some(standards) = rfc822::get_header_first(headers, "standards-version") {
168        let map = package.extra_data.get_or_insert_with(HashMap::new);
169        map.insert("standards_version".to_string(), standards.into());
170    }
171
172    package
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::models::DatasourceId;
179    use std::path::PathBuf;
180
181    #[test]
182    fn test_dsc_parser_is_match() {
183        assert!(DebianDscParser::is_match(&PathBuf::from("package.dsc")));
184        assert!(DebianDscParser::is_match(&PathBuf::from(
185            "adduser_3.118+deb11u1.dsc"
186        )));
187        assert!(!DebianDscParser::is_match(&PathBuf::from("control")));
188        assert!(!DebianDscParser::is_match(&PathBuf::from("package.txt")));
189    }
190
191    #[test]
192    fn test_dsc_parser_adduser() {
193        let path = PathBuf::from("testdata/debian/dsc_files/adduser_3.118+deb11u1.dsc");
194        let package = DebianDscParser::extract_first_package(&path);
195
196        assert_eq!(package.package_type, Some(PACKAGE_TYPE));
197        assert_eq!(package.namespace, Some("debian".to_string()));
198        assert_eq!(package.name, Some("adduser".to_string()));
199        assert_eq!(package.version, Some("3.118+deb11u1".to_string()));
200        assert_eq!(
201            package.purl,
202            Some("pkg:deb/debian/adduser@3.118%2Bdeb11u1?arch=all".to_string())
203        );
204        assert_eq!(
205            package.vcs_url,
206            Some("https://salsa.debian.org/debian/adduser.git".to_string())
207        );
208        assert_eq!(
209            package.code_view_url,
210            Some("https://salsa.debian.org/debian/adduser".to_string())
211        );
212        assert_eq!(
213            package.datasource_id,
214            Some(DatasourceId::DebianSourceControlDsc)
215        );
216
217        assert_eq!(package.parties.len(), 2);
218        assert_eq!(package.parties[0].role, Some("maintainer".to_string()));
219        assert_eq!(
220            package.parties[0].name,
221            Some("Debian Adduser Developers".to_string())
222        );
223        assert_eq!(
224            package.parties[0].email,
225            Some("adduser@packages.debian.org".to_string())
226        );
227        assert_eq!(package.parties[0].r#type, None);
228
229        assert_eq!(package.parties[1].role, Some("uploader".to_string()));
230        assert_eq!(package.parties[1].name, Some("Marc Haber".to_string()));
231        assert_eq!(
232            package.parties[1].email,
233            Some("mh+debian-packages@zugschlus.de".to_string())
234        );
235        assert_eq!(package.parties[1].r#type, None);
236
237        assert_eq!(package.source_packages.len(), 1);
238        assert_eq!(
239            package.source_packages[0],
240            "pkg:deb/debian/adduser".to_string()
241        );
242
243        assert!(!package.dependencies.is_empty());
244        let build_dep_names: Vec<String> = package
245            .dependencies
246            .iter()
247            .filter_map(|d| d.purl.as_ref())
248            .filter(|p| p.contains("po-debconf") || p.contains("debhelper"))
249            .map(|p| p.to_string())
250            .collect();
251        assert!(build_dep_names.len() >= 2);
252    }
253
254    #[test]
255    fn test_dsc_parser_zsh() {
256        let path = PathBuf::from("testdata/debian/dsc_files/zsh_5.7.1-1+deb10u1.dsc");
257        let package = DebianDscParser::extract_first_package(&path);
258
259        assert_eq!(package.name, Some("zsh".to_string()));
260        assert_eq!(package.version, Some("5.7.1-1+deb10u1".to_string()));
261        assert_eq!(package.namespace, Some("debian".to_string()));
262        assert!(package.purl.is_some());
263        assert!(package.purl.as_ref().unwrap().contains("zsh"));
264        assert!(package.purl.as_ref().unwrap().contains("5.7.1"));
265    }
266
267    #[test]
268    fn test_parse_dsc_content_basic() {
269        let content = "Format: 3.0 (native)
270Source: testpkg
271Binary: testpkg
272Architecture: amd64
273Version: 1.0.0
274Maintainer: Test User <test@example.com>
275Standards-Version: 4.5.0
276Build-Depends: debhelper (>= 12)
277Files:
278 abc123 1024 testpkg_1.0.0.tar.xz
279";
280
281        let package = parse_dsc_content(content);
282        assert_eq!(package.name, Some("testpkg".to_string()));
283        assert_eq!(package.version, Some("1.0.0".to_string()));
284        assert_eq!(package.namespace, Some("debian".to_string()));
285        assert_eq!(package.parties.len(), 1);
286        assert_eq!(package.parties[0].name, Some("Test User".to_string()));
287        assert_eq!(
288            package.parties[0].email,
289            Some("test@example.com".to_string())
290        );
291        assert_eq!(package.dependencies.len(), 1);
292        assert!(package.purl.as_ref().unwrap().contains("arch=amd64"));
293    }
294
295    #[test]
296    fn test_parse_dsc_content_with_uploaders() {
297        let content = "Source: mypkg
298Version: 2.0
299Architecture: all
300Maintainer: Main Dev <main@example.com>
301Uploaders: Dev One <dev1@example.com>, Dev Two <dev2@example.com>
302";
303
304        let package = parse_dsc_content(content);
305        assert_eq!(package.parties.len(), 3);
306        assert_eq!(package.parties[0].role, Some("maintainer".to_string()));
307        assert_eq!(package.parties[1].role, Some("uploader".to_string()));
308        assert_eq!(package.parties[2].role, Some("uploader".to_string()));
309    }
310}