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