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