1use std::collections::HashMap;
22use std::path::Path;
23
24use crate::parser_warn as warn;
25use crate::utils::magic;
26
27use crate::models::{
28 DatasourceId, Dependency, FileReference, LicenseDetection, PackageData, PackageType, Party,
29 Sha1Digest,
30};
31use crate::parsers::utils::{
32 MAX_ITERATION_COUNT, read_file_to_string, split_name_email, truncate_field,
33};
34
35const MAX_ARCHIVE_SIZE: u64 = 1024 * 1024 * 1024; const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; const MAX_COMPRESSION_RATIO: f64 = 100.0; use super::PackageParser;
40use super::license_normalization::{
41 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
42 build_declared_license_data_from_pair, combine_normalized_licenses,
43 empty_declared_license_data, normalize_declared_license_key,
44};
45
46const PACKAGE_TYPE: PackageType = PackageType::Alpine;
47
48fn default_package_data(datasource_id: DatasourceId) -> PackageData {
49 PackageData {
50 package_type: Some(PACKAGE_TYPE),
51 datasource_id: Some(datasource_id),
52 ..Default::default()
53 }
54}
55
56pub struct AlpineInstalledParser;
58
59pub struct AlpineApkbuildParser;
60
61impl PackageParser for AlpineInstalledParser {
62 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
63
64 fn is_match(path: &Path) -> bool {
65 path.to_str()
66 .map(|p| p.contains("/lib/apk/db/") && p.ends_with("installed"))
67 .unwrap_or(false)
68 }
69
70 fn extract_packages(path: &Path) -> Vec<PackageData> {
71 let content = match read_file_to_string(path, None) {
72 Ok(c) => c,
73 Err(e) => {
74 warn!("Failed to read Alpine installed db {:?}: {}", path, e);
75 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
76 }
77 };
78
79 parse_alpine_installed_db(&content)
80 }
81}
82
83impl PackageParser for AlpineApkbuildParser {
84 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
85
86 fn is_match(path: &Path) -> bool {
87 path.file_name().and_then(|n| n.to_str()) == Some("APKBUILD")
88 }
89
90 fn extract_packages(path: &Path) -> Vec<PackageData> {
91 let content = match read_file_to_string(path, None) {
92 Ok(c) => c,
93 Err(e) => {
94 warn!("Failed to read APKBUILD {:?}: {}", path, e);
95 return vec![default_package_data(DatasourceId::AlpineApkbuild)];
96 }
97 };
98
99 vec![parse_apkbuild(&content)]
100 }
101}
102
103fn parse_alpine_installed_db(content: &str) -> Vec<PackageData> {
104 let raw_paragraphs: Vec<&str> = content
105 .split("\n\n")
106 .filter(|p| !p.trim().is_empty())
107 .collect();
108
109 let mut all_packages = Vec::new();
110
111 for raw_text in raw_paragraphs.iter().take(MAX_ITERATION_COUNT) {
112 let headers = parse_alpine_headers(raw_text);
113 let pkg = parse_alpine_package_paragraph(&headers, raw_text);
114 if pkg.name.is_some() {
115 all_packages.push(pkg);
116 }
117 }
118
119 if all_packages.is_empty() {
120 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
121 }
122
123 all_packages
124}
125
126fn parse_alpine_headers(content: &str) -> HashMap<String, Vec<String>> {
132 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
133
134 for line in content.lines().take(MAX_ITERATION_COUNT) {
135 if line.is_empty() {
136 continue;
137 }
138
139 if let Some((key, value)) = line.split_once(':') {
140 let key = key.trim();
141 let value = value.trim();
142 if !key.is_empty() && !value.is_empty() {
143 headers
144 .entry(key.to_string())
145 .or_default()
146 .push(value.to_string());
147 }
148 }
149 }
150
151 headers
152}
153
154fn get_first(headers: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
155 headers
156 .get(key)
157 .and_then(|values| values.first())
158 .map(|v| truncate_field(v.trim().to_string()))
159}
160
161fn get_all(headers: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
162 headers
163 .get(key)
164 .cloned()
165 .unwrap_or_default()
166 .into_iter()
167 .filter(|v| !v.trim().is_empty())
168 .collect()
169}
170
171fn parse_alpine_package_paragraph(
172 headers: &HashMap<String, Vec<String>>,
173 raw_text: &str,
174) -> PackageData {
175 let name = get_first(headers, "P");
176 let version = get_first(headers, "V");
177 let description = get_first(headers, "T");
178 let homepage_url = get_first(headers, "U");
179 let architecture = get_first(headers, "A");
180
181 let is_virtual = description
182 .as_ref()
183 .is_some_and(|d| d == "virtual meta package");
184
185 let namespace = Some("alpine".to_string());
186 let mut parties = Vec::new();
187
188 if let Some(maintainer) = get_first(headers, "m") {
189 let (name_opt, email_opt) = split_name_email(&maintainer);
190 parties.push(Party {
191 r#type: None,
192 role: Some("maintainer".to_string()),
193 name: name_opt,
194 email: email_opt,
195 url: None,
196 organization: None,
197 organization_url: None,
198 timezone: None,
199 });
200 }
201
202 let extracted_license_statement = get_first(headers, "L");
203 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
204 build_alpine_license_data(extracted_license_statement.as_deref());
205
206 let source_packages = if let Some(origin) = get_first(headers, "o") {
207 vec![format!("pkg:alpine/{}", origin)]
208 } else {
209 Vec::new()
210 };
211 let vcs_url = get_first(headers, "c").map(|commit| {
212 truncate_field(format!(
213 "git+https://git.alpinelinux.org/aports/commit/?id={commit}"
214 ))
215 });
216
217 let mut dependencies = Vec::new();
218 let mut dep_count = 0;
219 'dep_loop: for dep in get_all(headers, "D") {
220 for dep_str in dep.split_whitespace() {
221 if dep_str.starts_with("so:") || dep_str.starts_with("cmd:") {
222 continue;
223 }
224
225 dep_count += 1;
226 if dep_count > MAX_ITERATION_COUNT {
227 warn!("Exceeded MAX_ITERATION_COUNT in dependency parsing, truncating");
228 break 'dep_loop;
229 }
230
231 dependencies.push(Dependency {
232 purl: Some(format!("pkg:alpine/{}", dep_str)),
233 extracted_requirement: None,
234 scope: Some("install".to_string()),
235 is_runtime: Some(true),
236 is_optional: Some(false),
237 is_direct: Some(true),
238 resolved_package: None,
239 extra_data: None,
240 is_pinned: Some(false),
241 });
242 }
243 }
244
245 let mut extra_data = HashMap::new();
246
247 if is_virtual {
248 extra_data.insert("is_virtual".to_string(), true.into());
249 }
250
251 if let Some(checksum) = get_first(headers, "C") {
252 extra_data.insert("checksum".to_string(), checksum.into());
253 }
254
255 if let Some(size) = get_first(headers, "S") {
256 extra_data.insert("compressed_size".to_string(), size.into());
257 }
258
259 if let Some(installed_size) = get_first(headers, "I") {
260 extra_data.insert("installed_size".to_string(), installed_size.into());
261 }
262
263 if let Some(timestamp) = get_first(headers, "t") {
264 extra_data.insert("build_timestamp".to_string(), timestamp.into());
265 }
266
267 if let Some(commit) = get_first(headers, "c") {
268 extra_data.insert("git_commit".to_string(), commit.into());
269 }
270
271 let providers = extract_providers(raw_text);
272 if !providers.is_empty() {
273 let provider_list: Vec<serde_json::Value> =
274 providers.into_iter().map(|s| s.into()).collect();
275 extra_data.insert("providers".to_string(), provider_list.into());
276 }
277
278 let file_references = extract_file_references(raw_text);
279
280 PackageData {
281 datasource_id: Some(DatasourceId::AlpineInstalledDb),
282 package_type: Some(PACKAGE_TYPE),
283 namespace: namespace.clone(),
284 name: name.clone(),
285 version: version.clone(),
286 description,
287 homepage_url,
288 vcs_url,
289 parties,
290 declared_license_expression,
291 declared_license_expression_spdx,
292 license_detections,
293 extracted_license_statement,
294 source_packages,
295 dependencies,
296 file_references,
297 purl: name
298 .as_ref()
299 .and_then(|n| build_alpine_purl(n, version.as_deref(), architecture.as_deref())),
300 extra_data: if extra_data.is_empty() {
301 None
302 } else {
303 Some(extra_data)
304 },
305 ..Default::default()
306 }
307}
308
309fn parse_apkbuild(content: &str) -> PackageData {
310 let variables = parse_apkbuild_variables(content);
311
312 let name = variables.get("pkgname").cloned().map(truncate_field);
313 let version = match (variables.get("pkgver"), variables.get("pkgrel")) {
314 (Some(ver), Some(rel)) => Some(truncate_field(format!("{}-r{}", ver, rel))),
315 (Some(ver), None) => Some(truncate_field(ver.clone())),
316 _ => None,
317 };
318 let description = variables.get("pkgdesc").cloned().map(truncate_field);
319 let homepage_url = variables.get("url").cloned().map(truncate_field);
320 let extracted_license_statement = variables.get("license").cloned().map(truncate_field);
321 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
322 build_alpine_license_data(extracted_license_statement.as_deref());
323
324 let dependencies = parse_apkbuild_dependencies(&variables);
325
326 let mut extra_data = HashMap::new();
327 if let Some(source) = variables.get("source") {
328 let sources_value: Vec<serde_json::Value> = parse_apkbuild_sources(source)
329 .into_iter()
330 .map(|(file_name, url)| serde_json::json!({ "file_name": file_name, "url": url }))
331 .collect();
332 if !sources_value.is_empty() {
333 extra_data.insert(
334 "sources".to_string(),
335 serde_json::Value::Array(sources_value),
336 );
337 }
338 }
339 for (field, checksum_key) in [
340 ("sha512sums", "sha512"),
341 ("sha256sums", "sha256"),
342 ("md5sums", "md5"),
343 ] {
344 if let Some(checksums) = variables.get(field) {
345 let checksum_entries: Vec<serde_json::Value> = parse_apkbuild_checksums(checksums)
346 .into_iter()
347 .map(|(file_name, checksum)| serde_json::json!({ "file_name": file_name, checksum_key: checksum }))
348 .collect();
349 if !checksum_entries.is_empty() {
350 match extra_data.get_mut("checksums") {
351 Some(serde_json::Value::Array(existing)) => existing.extend(checksum_entries),
352 _ => {
353 extra_data.insert(
354 "checksums".to_string(),
355 serde_json::Value::Array(checksum_entries),
356 );
357 }
358 }
359 }
360 }
361 }
362
363 PackageData {
364 datasource_id: Some(DatasourceId::AlpineApkbuild),
365 package_type: Some(PACKAGE_TYPE),
366 namespace: None,
367 name: name.clone(),
368 version: version.clone(),
369 description,
370 homepage_url,
371 extracted_license_statement,
372 declared_license_expression,
373 declared_license_expression_spdx,
374 license_detections,
375 dependencies,
376 purl: name
377 .as_deref()
378 .and_then(|n| build_alpine_purl(n, version.as_deref(), None)),
379 extra_data: (!extra_data.is_empty()).then_some(extra_data),
380 ..default_package_data(DatasourceId::AlpineApkbuild)
381 }
382}
383
384fn parse_apkbuild_variables(content: &str) -> HashMap<String, String> {
385 let mut raw = HashMap::new();
386 let mut lines = content.lines().peekable();
387 let mut brace_depth = 0usize;
388 let mut line_count = 0usize;
389
390 while let Some(line) = lines.next() {
391 line_count += 1;
392 if line_count > MAX_ITERATION_COUNT {
393 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_variables, truncating");
394 break;
395 }
396 let trimmed = line.trim();
397 if trimmed.is_empty() || trimmed.starts_with('#') {
398 continue;
399 }
400 if trimmed.ends_with("(){") || trimmed.ends_with("() {") {
401 brace_depth += 1;
402 continue;
403 }
404 if brace_depth > 0 {
405 brace_depth += trimmed.chars().filter(|c| *c == '{').count();
406 brace_depth = brace_depth.saturating_sub(trimmed.chars().filter(|c| *c == '}').count());
407 continue;
408 }
409 let Some((name, value)) = trimmed.split_once('=') else {
410 continue;
411 };
412 let mut value = value.trim().to_string();
413 if value.starts_with('"') && !value.ends_with('"') {
414 while let Some(next) = lines.peek() {
415 value.push('\n');
416 value.push_str(next);
417 let current = match lines.next() {
418 Some(l) => l,
419 None => break,
420 };
421 if current.trim_end().ends_with('"') {
422 break;
423 }
424 }
425 }
426 raw.insert(name.trim().to_string(), value);
427 }
428
429 let mut resolved = HashMap::new();
430 for key in [
431 "pkgname",
432 "pkgver",
433 "pkgrel",
434 "pkgdesc",
435 "url",
436 "license",
437 "source",
438 "depends",
439 "depends_dev",
440 "makedepends",
441 "makedepends_build",
442 "makedepends_host",
443 "checkdepends",
444 "sha512sums",
445 "sha256sums",
446 "md5sums",
447 ] {
448 if let Some(value) = raw.get(key) {
449 resolved.insert(key.to_string(), resolve_apkbuild_value(value, &raw));
450 }
451 }
452 resolved
453}
454
455fn resolve_apkbuild_value(value: &str, variables: &HashMap<String, String>) -> String {
456 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
457 for _ in 0..8 {
458 let previous = resolved.clone();
459 for (name, raw_value) in variables {
460 let raw_value = strip_wrapping_quotes(raw_value.trim());
461 let resolved_raw = resolve_apkbuild_value_no_recursion(raw_value, variables);
462 let value_resolved = strip_wrapping_quotes(&resolved_raw);
463 resolved = resolved.replace(
464 &format!("${{{name}//./-}}"),
465 &value_resolved.replace('.', "-"),
466 );
467 resolved = resolved.replace(
468 &format!("${{{name}//./_}}"),
469 &value_resolved.replace('.', "_"),
470 );
471 resolved = resolved.replace(
472 &format!("${{{name}::8}}"),
473 &value_resolved.chars().take(8).collect::<String>(),
474 );
475 resolved = resolved.replace(&format!("${{{name}}}"), value_resolved);
476 resolved = resolved.replace(&format!("${name}"), value_resolved);
477 }
478 if resolved == previous {
479 break;
480 }
481 }
482 resolved
483}
484
485fn resolve_apkbuild_value_no_recursion(value: &str, variables: &HashMap<String, String>) -> String {
486 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
487 for (name, raw_value) in variables {
488 let raw_value = strip_wrapping_quotes(raw_value.trim());
489 resolved = resolved.replace(&format!("${{{name}//./-}}"), &raw_value.replace('.', "-"));
490 resolved = resolved.replace(&format!("${{{name}//./_}}"), &raw_value.replace('.', "_"));
491 resolved = resolved.replace(
492 &format!("${{{name}::8}}"),
493 &raw_value.chars().take(8).collect::<String>(),
494 );
495 resolved = resolved.replace(&format!("${{{name}}}"), raw_value);
496 resolved = resolved.replace(&format!("${name}"), raw_value);
497 }
498 resolved
499}
500
501fn strip_wrapping_quotes(value: &str) -> &str {
502 value
503 .strip_prefix('"')
504 .and_then(|v| v.strip_suffix('"'))
505 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
506 .unwrap_or(value)
507}
508
509fn parse_apkbuild_sources(value: &str) -> Vec<(Option<String>, Option<String>)> {
510 value
511 .split_whitespace()
512 .filter(|part| !part.is_empty())
513 .map(|part| {
514 if let Some((file_name, url)) = part.split_once("::") {
515 (Some(file_name.to_string()), Some(url.to_string()))
516 } else if part.contains("://") {
517 (None, Some(part.to_string()))
518 } else {
519 (Some(part.to_string()), None)
520 }
521 })
522 .collect()
523}
524
525fn parse_apkbuild_checksums(value: &str) -> Vec<(String, String)> {
526 value
527 .lines()
528 .flat_map(|line| line.split_whitespace())
529 .collect::<Vec<_>>()
530 .chunks(2)
531 .filter_map(|chunk| {
532 if chunk.len() == 2 {
533 Some((chunk[1].to_string(), chunk[0].to_string()))
534 } else {
535 None
536 }
537 })
538 .collect()
539}
540
541fn build_alpine_license_data(
542 extracted: Option<&str>,
543) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
544 let Some(extracted) = extracted.map(str::trim).filter(|s| !s.is_empty()) else {
545 return empty_declared_license_data();
546 };
547
548 if extracted == "custom:multiple" {
549 return build_declared_license_data_from_pair(
550 "unknown-license-reference",
551 "LicenseRef-provenant-unknown-license-reference",
552 DeclaredLicenseMatchMetadata::single_line(extracted),
553 );
554 }
555
556 let normalized_tokens = extracted
557 .split_whitespace()
558 .filter(|part| *part != "AND")
559 .map(normalize_alpine_license_token)
560 .collect::<Option<Vec<_>>>();
561
562 let Some(normalized_tokens) = normalized_tokens else {
563 return empty_declared_license_data();
564 };
565
566 let Some(combined) = combine_normalized_licenses(normalized_tokens, " AND ") else {
567 return empty_declared_license_data();
568 };
569
570 build_declared_license_data(
571 combined,
572 DeclaredLicenseMatchMetadata::single_line(extracted),
573 )
574}
575
576fn normalize_alpine_license_token(token: &str) -> Option<NormalizedDeclaredLicense> {
577 match token {
578 "ICU" => Some(NormalizedDeclaredLicense::new("x11", "ICU")),
579 "Unicode-TOU" => Some(NormalizedDeclaredLicense::new("unicode-tou", "Unicode-TOU")),
580 "Ruby" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
581 "BSD-2-Clause" => Some(NormalizedDeclaredLicense::new(
582 "bsd-simplified",
583 "BSD-2-Clause",
584 )),
585 "BSD-3-Clause" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
586 other => normalize_declared_license_key(other),
587 }
588}
589
590fn parse_apkbuild_dependencies(variables: &HashMap<String, String>) -> Vec<Dependency> {
591 let mut dependencies = Vec::new();
592 let mut dep_count = 0;
593
594 for (field, scope, is_runtime, is_optional) in [
595 ("depends", "depends", true, false),
596 ("depends_dev", "depends_dev", false, true),
597 ("makedepends", "makedepends", false, true),
598 ("makedepends_build", "makedepends_build", false, true),
599 ("makedepends_host", "makedepends_host", false, true),
600 ("checkdepends", "checkdepends", false, true),
601 ] {
602 let Some(value) = variables.get(field) else {
603 continue;
604 };
605
606 for dep_str in value.split_whitespace() {
607 let dep_str = dep_str.trim();
608 if dep_str.is_empty() {
609 continue;
610 }
611
612 dep_count += 1;
613 if dep_count > MAX_ITERATION_COUNT {
614 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_dependencies, truncating");
615 return dependencies;
616 }
617
618 let dep_name = dep_str
619 .split(['<', '>', '=', '!', '~'])
620 .next()
621 .unwrap_or(dep_str)
622 .trim();
623 if dep_name.is_empty() {
624 continue;
625 }
626
627 dependencies.push(Dependency {
628 purl: build_alpine_purl(dep_name, None, None),
629 extracted_requirement: Some(dep_str.to_string()),
630 scope: Some(scope.to_string()),
631 is_runtime: Some(is_runtime),
632 is_optional: Some(is_optional),
633 is_pinned: Some(dep_str.contains('=')),
634 is_direct: Some(true),
635 resolved_package: None,
636 extra_data: None,
637 });
638 }
639 }
640
641 dependencies
642}
643
644fn extract_file_references(raw_text: &str) -> Vec<FileReference> {
645 let mut file_references = Vec::new();
646 let mut current_dir = String::new();
647 let mut current_file: Option<FileReference> = None;
648
649 for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
650 if line.is_empty() {
651 continue;
652 }
653
654 if let Some((field_type, value)) = line.split_once(':') {
655 let value = value.trim();
656 match field_type {
657 "F" => {
658 if let Some(file) = current_file.take() {
659 file_references.push(file);
660 }
661 current_dir = value.to_string();
662 }
663 "R" => {
664 if let Some(file) = current_file.take() {
665 file_references.push(file);
666 }
667
668 let path = if current_dir.is_empty() {
669 value.to_string()
670 } else {
671 format!("{}/{}", current_dir, value)
672 };
673
674 current_file = Some(FileReference {
675 path,
676 size: None,
677 sha1: None,
678 md5: None,
679 sha256: None,
680 sha512: None,
681 extra_data: None,
682 });
683 }
684 "Z" => {
685 if let Some(ref mut file) = current_file
686 && value.starts_with("Q1")
687 {
688 use base64::Engine;
689 if let Ok(decoded) =
690 base64::engine::general_purpose::STANDARD.decode(&value[2..])
691 && let Ok(digest) = Sha1Digest::from_hex(
692 &decoded
693 .iter()
694 .map(|b| format!("{:02x}", b))
695 .collect::<String>(),
696 )
697 {
698 file.sha1 = Some(digest);
699 }
700 }
701 }
702 "a" => {
703 if let Some(ref mut file) = current_file {
704 let mut extra = HashMap::new();
705 extra.insert(
706 "attributes".to_string(),
707 serde_json::Value::String(value.to_string()),
708 );
709 file.extra_data = Some(extra);
710 }
711 }
712 _ => {}
713 }
714 }
715 }
716
717 if let Some(file) = current_file {
718 file_references.push(file);
719 }
720
721 file_references
722}
723
724fn extract_providers(raw_text: &str) -> Vec<String> {
725 let mut providers = Vec::new();
726
727 for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
728 if line.is_empty() {
729 continue;
730 }
731
732 if let Some(value) = line.strip_prefix("p:") {
733 providers.extend(value.split_whitespace().map(|s| s.to_string()));
734 }
735 }
736
737 providers
738}
739
740fn build_alpine_purl(
741 name: &str,
742 version: Option<&str>,
743 architecture: Option<&str>,
744) -> Option<String> {
745 use packageurl::PackageUrl;
746
747 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
748
749 if let Some(ver) = version {
750 purl.with_version(ver).ok()?;
751 }
752
753 if let Some(arch) = architecture {
754 purl.add_qualifier("arch", arch).ok()?;
755 }
756
757 Some(purl.to_string())
758}
759
760pub struct AlpineApkParser;
762
763impl PackageParser for AlpineApkParser {
764 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
765
766 fn is_match(path: &Path) -> bool {
767 path.extension().and_then(|e| e.to_str()) == Some("apk")
768 && magic::is_gzip(path)
769 && !magic::is_zip(path)
770 && apk_contains_pkginfo(path)
771 }
772
773 fn extract_packages(path: &Path) -> Vec<PackageData> {
774 vec![match extract_apk_archive(path) {
775 Ok(data) => data,
776 Err(e) => {
777 warn!("Failed to extract .apk archive {:?}: {}", path, e);
778 PackageData {
779 package_type: Some(PACKAGE_TYPE),
780 datasource_id: Some(DatasourceId::AlpineApkArchive),
781 ..Default::default()
782 }
783 }
784 }]
785 }
786}
787
788fn apk_contains_pkginfo(path: &Path) -> bool {
789 use flate2::read::GzDecoder;
790
791 let file = match std::fs::File::open(path) {
792 Ok(file) => file,
793 Err(_) => return false,
794 };
795
796 let archive_size = match std::fs::metadata(path) {
797 Ok(m) => m.len(),
798 Err(_) => return false,
799 };
800
801 if archive_size > MAX_ARCHIVE_SIZE {
802 warn!(
803 "Archive {:?} exceeds MAX_ARCHIVE_SIZE ({} bytes)",
804 path, archive_size
805 );
806 return false;
807 }
808
809 let decoder = GzDecoder::new(file);
810 let mut archive = tar::Archive::new(decoder);
811 let entries = match archive.entries() {
812 Ok(entries) => entries,
813 Err(_) => return false,
814 };
815
816 let mut total_extracted: u64 = 0;
817
818 for entry_result in entries {
819 let entry = match entry_result {
820 Ok(entry) => entry,
821 Err(_) => return false,
822 };
823 let entry_path = match entry.path() {
824 Ok(path) => path,
825 Err(_) => return false,
826 };
827
828 let entry_str = entry_path.to_string_lossy();
829 if entry_str.contains("..") {
830 warn!("Skipping tar entry with path traversal: {}", entry_str);
831 continue;
832 }
833
834 let uncompressed_size = entry.size();
835 if uncompressed_size > MAX_FILE_SIZE {
836 warn!(
837 "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
838 entry_path, path, uncompressed_size
839 );
840 continue;
841 }
842
843 if archive_size > 0 {
844 let ratio = uncompressed_size as f64 / archive_size as f64;
845 if ratio > MAX_COMPRESSION_RATIO {
846 warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
847 continue;
848 }
849 }
850
851 total_extracted += uncompressed_size;
852 if total_extracted > MAX_ARCHIVE_SIZE {
853 warn!("Total extracted size exceeds limit for {:?}", path);
854 return false;
855 }
856
857 if entry_path.ends_with(".PKGINFO") {
858 return true;
859 }
860 }
861
862 false
863}
864
865fn extract_apk_archive(path: &Path) -> Result<PackageData, String> {
866 use flate2::read::GzDecoder;
867 use std::io::Read;
868
869 let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .apk file: {}", e))?;
870
871 let archive_size = std::fs::metadata(path)
872 .map_err(|e| format!("Failed to stat .apk file: {}", e))?
873 .len();
874
875 if archive_size > MAX_ARCHIVE_SIZE {
876 return Err(format!(
877 "Archive {:?} is {} bytes, exceeding MAX_ARCHIVE_SIZE ({} bytes)",
878 path, archive_size, MAX_ARCHIVE_SIZE
879 ));
880 }
881
882 let decoder = GzDecoder::new(file);
883 let mut archive = tar::Archive::new(decoder);
884
885 let mut total_extracted: u64 = 0;
886
887 for entry_result in archive
888 .entries()
889 .map_err(|e| format!("Failed to read tar entries: {}", e))?
890 {
891 let mut entry = entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
892
893 let entry_path = entry
894 .path()
895 .map_err(|e| format!("Failed to get entry path: {}", e))?;
896
897 let entry_str = entry_path.to_string_lossy();
898 if entry_str.contains("..") {
899 warn!("Skipping tar entry with path traversal: {}", entry_str);
900 continue;
901 }
902
903 let uncompressed_size = entry.size();
904 if uncompressed_size > MAX_FILE_SIZE {
905 warn!(
906 "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
907 entry_path, path, uncompressed_size
908 );
909 continue;
910 }
911
912 if archive_size > 0 {
913 let ratio = uncompressed_size as f64 / archive_size as f64;
914 if ratio > MAX_COMPRESSION_RATIO {
915 warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
916 continue;
917 }
918 }
919
920 total_extracted += uncompressed_size;
921 if total_extracted > MAX_ARCHIVE_SIZE {
922 return Err(format!("Total extracted size exceeds limit for {:?}", path));
923 }
924
925 if entry_path.ends_with(".PKGINFO") {
926 let mut content = String::new();
927 entry
928 .read_to_string(&mut content)
929 .map_err(|e| format!("Failed to read .PKGINFO: {}", e))?;
930
931 return Ok(parse_pkginfo(&content));
932 }
933 }
934
935 Err(".apk archive does not contain .PKGINFO file".to_string())
936}
937
938fn parse_pkginfo(content: &str) -> PackageData {
939 let mut fields: HashMap<&str, Vec<&str>> = HashMap::new();
940
941 for line in content.lines().take(MAX_ITERATION_COUNT) {
942 let line = line.trim();
943 if line.is_empty() || line.starts_with('#') {
944 continue;
945 }
946
947 if let Some((key, value)) = line.split_once(" = ") {
948 fields.entry(key.trim()).or_default().push(value.trim());
949 }
950 }
951
952 let name = fields
953 .get("pkgname")
954 .and_then(|v| v.first())
955 .map(|s| truncate_field(s.to_string()));
956 let pkgver = fields.get("pkgver").and_then(|v| v.first());
957 let version = pkgver.map(|s| truncate_field(s.to_string()));
958 let arch = fields
959 .get("arch")
960 .and_then(|v| v.first())
961 .map(|s| truncate_field(s.to_string()));
962 let license = fields
963 .get("license")
964 .and_then(|v| v.first())
965 .map(|s| truncate_field(s.to_string()));
966 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
967 build_alpine_license_data(license.as_deref());
968 let description = fields
969 .get("pkgdesc")
970 .and_then(|v| v.first())
971 .map(|s| truncate_field(s.to_string()));
972 let homepage = fields
973 .get("url")
974 .and_then(|v| v.first())
975 .map(|s| truncate_field(s.to_string()));
976 let origin = fields
977 .get("origin")
978 .and_then(|v| v.first())
979 .map(|s| truncate_field(s.to_string()));
980 let maintainer_str = fields.get("maintainer").and_then(|v| v.first());
981
982 let mut parties = Vec::new();
983 if let Some(maint) = maintainer_str {
984 let (maint_name, maint_email) = split_name_email(maint);
985 parties.push(Party {
986 r#type: Some("person".to_string()),
987 role: Some("maintainer".to_string()),
988 name: maint_name,
989 email: maint_email,
990 url: None,
991 organization: None,
992 organization_url: None,
993 timezone: None,
994 });
995 }
996
997 let purl = name
998 .as_ref()
999 .and_then(|n| build_alpine_purl(n, version.as_deref(), arch.as_deref()));
1000
1001 let mut dependencies = Vec::new();
1002 if let Some(depends_list) = fields.get("depend") {
1003 for (i, dep_str) in depends_list.iter().enumerate() {
1004 if i >= MAX_ITERATION_COUNT {
1005 warn!("Exceeded MAX_ITERATION_COUNT in parse_pkginfo dependencies, truncating");
1006 break;
1007 }
1008 let dep_name = dep_str.split_whitespace().next().unwrap_or(dep_str);
1009 dependencies.push(Dependency {
1010 purl: Some(format!("pkg:alpine/{}", dep_name)),
1011 extracted_requirement: Some(dep_str.to_string()),
1012 scope: Some("runtime".to_string()),
1013 is_runtime: Some(true),
1014 is_optional: Some(false),
1015 is_pinned: None,
1016 is_direct: Some(true),
1017 resolved_package: None,
1018 extra_data: None,
1019 });
1020 }
1021 }
1022
1023 PackageData {
1024 datasource_id: Some(DatasourceId::AlpineApkArchive),
1025 package_type: Some(PACKAGE_TYPE),
1026 namespace: Some("alpine".to_string()),
1027 name,
1028 version,
1029 description,
1030 homepage_url: homepage,
1031 declared_license_expression,
1032 declared_license_expression_spdx,
1033 license_detections,
1034 extracted_license_statement: license,
1035 parties,
1036 dependencies,
1037 purl,
1038 extra_data: origin.map(|o| {
1039 let mut map = HashMap::new();
1040 map.insert("origin".to_string(), serde_json::Value::String(o));
1041 map
1042 }),
1043 ..Default::default()
1044 }
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050 use std::io::Write;
1051 use std::path::PathBuf;
1052 use tempfile::TempDir;
1053
1054 fn create_temp_installed_db(content: &str) -> (TempDir, PathBuf) {
1057 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1058 let db_dir = temp_dir.path().join("lib/apk/db");
1059 std::fs::create_dir_all(&db_dir).expect("Failed to create db dir");
1060 let file_path = db_dir.join("installed");
1061 let mut file = std::fs::File::create(&file_path).expect("Failed to create file");
1062 file.write_all(content.as_bytes())
1063 .expect("Failed to write content");
1064 (temp_dir, file_path)
1065 }
1066
1067 #[test]
1068 fn test_alpine_parser_is_match() {
1069 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1070 "/lib/apk/db/installed"
1071 )));
1072 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1073 "/var/lib/apk/db/installed"
1074 )));
1075 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1076 "/lib/apk/db/status"
1077 )));
1078 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1079 "installed"
1080 )));
1081 }
1082
1083 #[test]
1084 fn test_parse_alpine_package_basic() {
1085 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1086P:alpine-baselayout-data
1087V:3.2.0-r22
1088A:x86_64
1089S:11435
1090I:73728
1091T:Alpine base dir structure and init scripts
1092U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
1093L:GPL-2.0-only
1094o:alpine-baselayout
1095m:Natanael Copa <ncopa@alpinelinux.org>
1096t:1655134784
1097c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1098
1099";
1100 let (_dir, path) = create_temp_installed_db(content);
1101 let pkg = AlpineInstalledParser::extract_first_package(&path);
1102 assert_eq!(pkg.name, Some("alpine-baselayout-data".to_string()));
1103 assert_eq!(pkg.version, Some("3.2.0-r22".to_string()));
1104 assert_eq!(pkg.namespace, Some("alpine".to_string()));
1105 assert_eq!(
1106 pkg.description,
1107 Some("Alpine base dir structure and init scripts".to_string())
1108 );
1109 assert_eq!(
1110 pkg.homepage_url,
1111 Some("https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout".to_string())
1112 );
1113 assert_eq!(
1114 pkg.extracted_license_statement,
1115 Some("GPL-2.0-only".to_string())
1116 );
1117 assert_eq!(pkg.parties.len(), 1);
1118 assert_eq!(pkg.parties[0].name, Some("Natanael Copa".to_string()));
1119 assert_eq!(
1120 pkg.parties[0].email,
1121 Some("ncopa@alpinelinux.org".to_string())
1122 );
1123 assert!(
1124 pkg.purl
1125 .as_ref()
1126 .unwrap()
1127 .contains("alpine-baselayout-data")
1128 );
1129 assert!(pkg.purl.as_ref().unwrap().contains("arch=x86_64"));
1130 }
1131
1132 #[test]
1133 fn test_parse_alpine_with_dependencies() {
1134 let content = "P:musl
1135V:1.2.3-r0
1136A:x86_64
1137D:scanelf so:libc.musl-x86_64.so.1
1138
1139";
1140 let (_dir, path) = create_temp_installed_db(content);
1141 let pkg = AlpineInstalledParser::extract_first_package(&path);
1142 assert_eq!(pkg.name, Some("musl".to_string()));
1143 assert_eq!(pkg.dependencies.len(), 1);
1144 assert!(
1145 pkg.dependencies[0]
1146 .purl
1147 .as_ref()
1148 .unwrap()
1149 .contains("scanelf")
1150 );
1151 }
1152
1153 #[test]
1154 fn test_build_alpine_purl() {
1155 let purl = build_alpine_purl("busybox", Some("1.31.1-r9"), Some("x86_64"));
1156 assert_eq!(
1157 purl,
1158 Some("pkg:alpine/busybox@1.31.1-r9?arch=x86_64".to_string())
1159 );
1160
1161 let purl_no_arch = build_alpine_purl("package", Some("1.0"), None);
1162 assert_eq!(purl_no_arch, Some("pkg:alpine/package@1.0".to_string()));
1163 }
1164
1165 #[test]
1166 fn test_parse_alpine_extra_data() {
1167 let content = "P:test-package
1168V:1.0
1169C:base64checksum==
1170S:12345
1171I:67890
1172t:1234567890
1173c:gitcommithash
1174
1175";
1176 let (_dir, path) = create_temp_installed_db(content);
1177 let pkg = AlpineInstalledParser::extract_first_package(&path);
1178 assert!(pkg.extra_data.is_some());
1179 let extra = pkg.extra_data.as_ref().unwrap();
1180 assert_eq!(extra["checksum"], "base64checksum==");
1181 assert_eq!(extra["compressed_size"], "12345");
1182 assert_eq!(extra["installed_size"], "67890");
1183 assert_eq!(extra["build_timestamp"], "1234567890");
1184 assert_eq!(extra["git_commit"], "gitcommithash");
1185 }
1186
1187 #[test]
1188 fn test_parse_alpine_case_sensitive_keys() {
1189 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1190P:test-pkg
1191V:1.0
1192T:A test description
1193t:1655134784
1194c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1195
1196";
1197 let (_dir, path) = create_temp_installed_db(content);
1198 let pkg = AlpineInstalledParser::extract_first_package(&path);
1199 assert_eq!(pkg.description, Some("A test description".to_string()));
1200 let extra = pkg.extra_data.as_ref().unwrap();
1201 assert_eq!(extra["checksum"], "Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=");
1202 assert_eq!(extra["build_timestamp"], "1655134784");
1203 assert_eq!(
1204 extra["git_commit"],
1205 "cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_parse_alpine_multiple_packages() {
1211 let content = "P:package1
1212V:1.0
1213A:x86_64
1214
1215P:package2
1216V:2.0
1217A:aarch64
1218
1219";
1220 let (_dir, path) = create_temp_installed_db(content);
1221 let pkgs = AlpineInstalledParser::extract_packages(&path);
1222 assert_eq!(pkgs.len(), 2);
1223 assert_eq!(pkgs[0].name, Some("package1".to_string()));
1224 assert_eq!(pkgs[0].version, Some("1.0".to_string()));
1225 assert_eq!(pkgs[1].name, Some("package2".to_string()));
1226 assert_eq!(pkgs[1].version, Some("2.0".to_string()));
1227 }
1228
1229 #[test]
1230 fn test_parse_alpine_file_references() {
1231 let content = "P:test-pkg
1232V:1.0
1233F:usr/bin
1234R:test
1235Z:Q1WTc55xfvPogzA0YUV24D0Ym+MKE=
1236F:etc
1237R:config
1238Z:Q1pcfTfDNEbNKQc2s1tia7da05M8Q=
1239
1240";
1241 let (_dir, path) = create_temp_installed_db(content);
1242 let pkg = AlpineInstalledParser::extract_first_package(&path);
1243 assert_eq!(pkg.file_references.len(), 2);
1244 assert_eq!(pkg.file_references[0].path, "usr/bin/test");
1245 assert!(pkg.file_references[0].sha1.is_some());
1246 assert_eq!(pkg.file_references[1].path, "etc/config");
1247 assert!(pkg.file_references[1].sha1.is_some());
1248 }
1249
1250 #[test]
1251 fn test_parse_alpine_empty_fields() {
1252 let content = "P:minimal-package
1253V:1.0
1254
1255";
1256 let (_dir, path) = create_temp_installed_db(content);
1257 let pkg = AlpineInstalledParser::extract_first_package(&path);
1258 assert_eq!(pkg.name, Some("minimal-package".to_string()));
1259 assert_eq!(pkg.version, Some("1.0".to_string()));
1260 assert!(pkg.description.is_none());
1261 assert!(pkg.homepage_url.is_none());
1262 assert_eq!(pkg.dependencies.len(), 0);
1263 }
1264
1265 #[test]
1266 fn test_parse_alpine_origin_field() {
1267 let content = "P:busybox-ifupdown
1268V:1.35.0-r13
1269o:busybox
1270A:x86_64
1271
1272";
1273 let (_dir, path) = create_temp_installed_db(content);
1274 let pkg = AlpineInstalledParser::extract_first_package(&path);
1275 assert_eq!(pkg.name, Some("busybox-ifupdown".to_string()));
1276 assert_eq!(pkg.source_packages.len(), 1);
1277 assert_eq!(pkg.source_packages[0], "pkg:alpine/busybox");
1278 }
1279
1280 #[test]
1281 fn test_parse_alpine_url_field() {
1282 let content = "P:openssl
1283V:1.1.1q-r0
1284U:https://www.openssl.org
1285A:x86_64
1286
1287";
1288 let (_dir, path) = create_temp_installed_db(content);
1289 let pkg = AlpineInstalledParser::extract_first_package(&path);
1290 assert_eq!(
1291 pkg.homepage_url,
1292 Some("https://www.openssl.org".to_string())
1293 );
1294 }
1295
1296 #[test]
1297 fn test_parse_alpine_provider_field() {
1298 let content = "P:some-package
1299V:1.0
1300p:cmd:binary=1.0
1301p:so:libtest.so.1
1302
1303";
1304 let (_dir, path) = create_temp_installed_db(content);
1305 let pkg = AlpineInstalledParser::extract_first_package(&path);
1306 assert!(pkg.extra_data.is_some());
1307 let extra = pkg.extra_data.as_ref().unwrap();
1308 let providers = extra.get("providers").and_then(|v| v.as_array());
1309 assert!(providers.is_some());
1310 let provider_array = providers.unwrap();
1311 assert_eq!(provider_array.len(), 2);
1312 assert_eq!(provider_array[0].as_str(), Some("cmd:binary=1.0"));
1313 assert_eq!(provider_array[1].as_str(), Some("so:libtest.so.1"));
1314 }
1315
1316 #[test]
1317 fn test_alpine_apk_parser_is_match() {
1318 let apk_path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1319
1320 assert!(AlpineApkParser::is_match(&apk_path));
1321 assert!(!AlpineApkParser::is_match(&PathBuf::from("package.tar.gz")));
1322 assert!(!AlpineApkParser::is_match(&PathBuf::from("installed")));
1323 }
1324
1325 #[test]
1326 fn test_alpine_apk_parser_rejects_android_and_placeholder_apk_fixtures() {
1327 let android_apk = PathBuf::from("testdata/misc/test_android.apk");
1328 let placeholder_alpine_apk = PathBuf::from("testdata/misc/test_alpine.apk");
1329 let valid_alpine_apk = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1330
1331 assert!(!AlpineApkParser::is_match(&android_apk));
1332 assert!(!AlpineApkParser::is_match(&placeholder_alpine_apk));
1333 assert!(AlpineApkParser::is_match(&valid_alpine_apk));
1334 }
1335
1336 #[test]
1337 fn test_alpine_apkbuild_parser_is_match() {
1338 assert!(AlpineApkbuildParser::is_match(&PathBuf::from("APKBUILD")));
1339 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1340 "/path/to/APKBUILD"
1341 )));
1342 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from("apkbuild")));
1343 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from(
1344 "APKBUILD.txt"
1345 )));
1346 }
1347
1348 #[test]
1349 fn test_parse_apkbuild_icu_reference() {
1350 let path = PathBuf::from("testdata/alpine-fixtures/apkbuild/alpine14/main/icu/APKBUILD");
1351 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1352
1353 assert_eq!(pkg.datasource_id, Some(DatasourceId::AlpineApkbuild));
1354 assert_eq!(pkg.name.as_deref(), Some("icu"));
1355 assert_eq!(pkg.version.as_deref(), Some("67.1-r2"));
1356 assert_eq!(
1357 pkg.description.as_deref(),
1358 Some("International Components for Unicode library")
1359 );
1360 assert_eq!(
1361 pkg.homepage_url.as_deref(),
1362 Some("http://site.icu-project.org/")
1363 );
1364 assert_eq!(
1365 pkg.extracted_license_statement.as_deref(),
1366 Some("MIT ICU Unicode-TOU")
1367 );
1368 assert_eq!(
1369 pkg.declared_license_expression_spdx.as_deref(),
1370 Some("ICU AND MIT AND Unicode-TOU")
1371 );
1372 assert_eq!(pkg.dependencies.len(), 3);
1373 let depends_dev = pkg
1374 .dependencies
1375 .iter()
1376 .find(|dep| dep.scope.as_deref() == Some("depends_dev"))
1377 .expect("depends_dev dependency missing");
1378 assert_eq!(depends_dev.purl.as_deref(), Some("pkg:alpine/icu"));
1379 assert_eq!(depends_dev.is_runtime, Some(false));
1380 assert_eq!(depends_dev.is_optional, Some(true));
1381
1382 let check_dep_names: Vec<_> = pkg
1383 .dependencies
1384 .iter()
1385 .filter(|dep| dep.scope.as_deref() == Some("checkdepends"))
1386 .filter_map(|dep| dep.purl.as_deref())
1387 .collect();
1388 assert!(check_dep_names.contains(&"pkg:alpine/diffutils"));
1389 assert!(check_dep_names.contains(&"pkg:alpine/python3"));
1390 let extra = pkg.extra_data.as_ref().unwrap();
1391 assert!(extra.contains_key("sources"));
1392 assert!(extra.contains_key("checksums"));
1393 }
1394
1395 #[test]
1396 fn test_parse_apkbuild_custom_multiple_license_uses_raw_matched_text() {
1397 let path = PathBuf::from(
1398 "testdata/alpine-fixtures/apkbuild/alpine13/main/linux-firmware/APKBUILD",
1399 );
1400 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1401
1402 assert_eq!(pkg.name.as_deref(), Some("linux-firmware"));
1403 assert_eq!(pkg.version.as_deref(), Some("20201218-r0"));
1404 assert_eq!(
1405 pkg.extracted_license_statement.as_deref(),
1406 Some("custom:multiple")
1407 );
1408 assert_eq!(
1409 pkg.declared_license_expression.as_deref(),
1410 Some("unknown-license-reference")
1411 );
1412 assert_eq!(
1413 pkg.declared_license_expression_spdx.as_deref(),
1414 Some("LicenseRef-provenant-unknown-license-reference")
1415 );
1416 let matched = pkg.license_detections[0].matches[0].matched_text.as_deref();
1417 assert_eq!(matched, Some("custom:multiple"));
1418 }
1419
1420 #[test]
1421 fn test_parse_alpine_no_files_package_still_detected() {
1422 let path = PathBuf::from("testdata/alpine-fixtures/full-installed/installed");
1423 let content = std::fs::read_to_string(&path).expect("read installed db fixture");
1424 let packages = parse_alpine_installed_db(&content);
1425 let libc_utils = packages
1426 .into_iter()
1427 .find(|pkg| pkg.name.as_deref() == Some("libc-utils"))
1428 .expect("libc-utils package should exist");
1429
1430 assert_eq!(libc_utils.file_references.len(), 0);
1431 assert!(
1432 libc_utils
1433 .purl
1434 .as_deref()
1435 .is_some_and(|p| p.contains("libc-utils"))
1436 );
1437 }
1438
1439 #[test]
1440 fn test_parse_alpine_commit_generates_https_vcs_url() {
1441 let content =
1442 "P:test-package\nV:1.0-r0\nA:x86_64\nc:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd\n";
1443 let (_dir, path) = create_temp_installed_db(content);
1444 let pkg = AlpineInstalledParser::extract_first_package(&path);
1445
1446 assert_eq!(
1447 pkg.vcs_url.as_deref(),
1448 Some(
1449 "git+https://git.alpinelinux.org/aports/commit/?id=cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1450 )
1451 );
1452 }
1453
1454 #[test]
1455 fn test_parse_alpine_virtual_package() {
1456 let content = "P:.postgis-rundeps
1457V:20210104.190748
1458A:noarch
1459S:0
1460I:0
1461T:virtual meta package
1462U:
1463L:
1464D:json-c geos gdal proj protobuf-c libstdc++
1465
1466";
1467 let (_dir, path) = create_temp_installed_db(content);
1468 let pkg = AlpineInstalledParser::extract_first_package(&path);
1469 assert_eq!(pkg.name, Some(".postgis-rundeps".to_string()));
1470 assert_eq!(pkg.version, Some("20210104.190748".to_string()));
1471 assert_eq!(pkg.description, Some("virtual meta package".to_string()));
1472 assert!(pkg.extra_data.is_some());
1473 let extra = pkg.extra_data.as_ref().unwrap();
1474 assert_eq!(
1475 extra.get("is_virtual").and_then(|v| v.as_bool()),
1476 Some(true)
1477 );
1478 assert_eq!(pkg.dependencies.len(), 6);
1479 assert!(pkg.homepage_url.is_none());
1480 assert!(pkg.extracted_license_statement.is_none());
1481 }
1482
1483 #[test]
1484 fn test_installed_db_license_normalization() {
1485 let content = "P:test-package\nV:1.0-r0\nA:x86_64\nL:MIT\n\n";
1486 let (_dir, path) = create_temp_installed_db(content);
1487 let pkg = AlpineInstalledParser::extract_first_package(&path);
1488
1489 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1490 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1491 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1492 assert_eq!(pkg.license_detections.len(), 1);
1493 }
1494
1495 #[test]
1496 fn test_apk_archive_license_normalization() {
1497 let path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1498 let pkg = AlpineApkParser::extract_first_package(&path);
1499
1500 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1501 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1502 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1503 assert_eq!(pkg.license_detections.len(), 1);
1504 }
1505}
1506
1507crate::register_parser!(
1508 "Alpine Linux package (installed db and .apk archive)",
1509 &["**/lib/apk/db/installed", "**/*.apk"],
1510 "alpine",
1511 "",
1512 Some("https://wiki.alpinelinux.org/wiki/Apk_spec"),
1513);
1514
1515crate::register_parser!(
1516 "Alpine Linux APKBUILD recipe",
1517 &["**/APKBUILD"],
1518 "alpine",
1519 "Shell",
1520 Some("https://wiki.alpinelinux.org/wiki/APKBUILD_Reference"),
1521);