provenant/parsers/debian/
dsc.rs1use 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
13pub 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 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 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 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 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}