Skip to main content

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