Skip to main content

provenant/parsers/debian/
dsc.rs

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