Skip to main content

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