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