Skip to main content

provenant/parsers/debian/
dsc.rs

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