1use std::collections::HashMap;
28use std::path::Path;
29
30use crate::parser_warn as warn;
31use crate::utils::magic;
32
33use super::metadata::ParserMetadata;
34use crate::models::{
35 DatasourceId, Dependency, FileReference, LicenseDetection, PackageData, PackageType, Party,
36 PartyType, Sha1Digest,
37};
38use crate::parsers::utils::{
39 CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string,
40 split_name_email, truncate_field,
41};
42
43const 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;
48use super::license_normalization::{
49 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
50 build_declared_license_data_from_pair, combine_normalized_licenses,
51 empty_declared_license_data, normalize_declared_license_key,
52};
53
54const PACKAGE_TYPE: PackageType = PackageType::Apk;
55
56const ALPINE_VENDOR_NAMESPACE: &str = "alpine";
58
59fn default_package_data(datasource_id: DatasourceId) -> PackageData {
60 PackageData {
61 package_type: Some(PACKAGE_TYPE),
62 datasource_id: Some(datasource_id),
63 ..Default::default()
64 }
65}
66
67pub struct AlpineInstalledParser;
69
70pub struct AlpineApkbuildParser;
71
72impl PackageParser for AlpineInstalledParser {
73 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
74
75 fn is_match(path: &Path) -> bool {
76 path.to_str()
77 .map(|p| p.contains("/lib/apk/db/") && p.ends_with("installed"))
78 .unwrap_or(false)
79 }
80
81 fn extract_packages(path: &Path) -> Vec<PackageData> {
82 let content = match read_file_to_string(path, None) {
83 Ok(c) => c,
84 Err(e) => {
85 warn!("Failed to read Alpine installed db {:?}: {}", path, e);
86 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
87 }
88 };
89
90 parse_alpine_installed_db(&content)
91 }
92}
93
94impl PackageParser for AlpineApkbuildParser {
95 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
96
97 fn metadata() -> Vec<ParserMetadata> {
98 vec![ParserMetadata {
99 description: "Alpine Linux APKBUILD recipe",
100 file_patterns: &["**/APKBUILD"],
101 package_type: "apk",
102 primary_language: "Shell",
103 documentation_url: Some(
104 "https://github.com/alpinelinux/abuild/blob/master/APKBUILD.5.scd",
105 ),
106 }]
107 }
108
109 fn is_match(path: &Path) -> bool {
110 path.file_name()
111 .and_then(|n| n.to_str())
112 .is_some_and(|name| {
113 name == "APKBUILD" || name.ends_with("-APKBUILD") || name.ends_with("_APKBUILD")
114 })
115 }
116
117 fn extract_packages(path: &Path) -> Vec<PackageData> {
118 let content = match read_file_to_string(path, None) {
119 Ok(c) => c,
120 Err(e) => {
121 warn!("Failed to read APKBUILD {:?}: {}", path, e);
122 return vec![default_package_data(DatasourceId::AlpineApkbuild)];
123 }
124 };
125
126 vec![parse_apkbuild(&content)]
127 }
128}
129
130fn parse_alpine_installed_db(content: &str) -> Vec<PackageData> {
131 let raw_paragraphs: Vec<&str> = content
132 .split("\n\n")
133 .filter(|p| !p.trim().is_empty())
134 .collect();
135
136 let mut all_packages = Vec::new();
137
138 let limit = capped_iteration_limit(raw_paragraphs.len(), "alpine installed DB packages");
139 for raw_text in raw_paragraphs.iter().take(limit) {
140 let headers = parse_alpine_headers(raw_text);
141 let pkg = parse_alpine_package_paragraph(&headers, raw_text);
142 if pkg.name.is_some() {
143 all_packages.push(pkg);
144 }
145 }
146
147 if all_packages.is_empty() {
148 return vec![default_package_data(DatasourceId::AlpineInstalledDb)];
149 }
150
151 all_packages
152}
153
154fn parse_alpine_headers(content: &str) -> HashMap<String, Vec<String>> {
160 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
161
162 for line in content.lines().capped("alpine DB header lines") {
163 if line.is_empty() {
164 continue;
165 }
166
167 if let Some((key, value)) = line.split_once(':') {
168 let key = key.trim();
169 let value = value.trim();
170 if !key.is_empty() && !value.is_empty() {
171 headers
172 .entry(key.to_string())
173 .or_default()
174 .push(value.to_string());
175 }
176 }
177 }
178
179 headers
180}
181
182fn get_first(headers: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
183 headers
184 .get(key)
185 .and_then(|values| values.first())
186 .map(|v| truncate_field(v.trim().to_string()))
187}
188
189fn get_all(headers: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
190 headers
191 .get(key)
192 .cloned()
193 .unwrap_or_default()
194 .into_iter()
195 .filter(|v| !v.trim().is_empty())
196 .collect()
197}
198
199fn parse_alpine_package_paragraph(
200 headers: &HashMap<String, Vec<String>>,
201 raw_text: &str,
202) -> PackageData {
203 let name = get_first(headers, "P");
204 let version = get_first(headers, "V");
205 let description = get_first(headers, "T");
206 let homepage_url = get_first(headers, "U");
207 let architecture = get_first(headers, "A");
208
209 let is_virtual = description
210 .as_ref()
211 .is_some_and(|d| d == "virtual meta package");
212
213 let namespace = Some("alpine".to_string());
214 let mut parties = Vec::new();
215
216 if let Some(maintainer) = get_first(headers, "m") {
217 let (name_opt, email_opt) = split_name_email(&maintainer);
218 parties.push(Party {
219 r#type: None,
220 role: Some("maintainer".to_string()),
221 name: name_opt,
222 email: email_opt,
223 url: None,
224 organization: None,
225 organization_url: None,
226 timezone: None,
227 });
228 }
229
230 let extracted_license_statement = get_first(headers, "L");
231 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
232 build_alpine_license_data(extracted_license_statement.as_deref());
233
234 let source_packages = if let Some(origin) = get_first(headers, "o") {
235 crate::parsers::utils::namespaced_purl("apk", "alpine", &origin, None)
236 .into_iter()
237 .collect()
238 } else {
239 Vec::new()
240 };
241 let vcs_url = get_first(headers, "c").map(|commit| {
242 truncate_field(format!(
243 "git+https://git.alpinelinux.org/aports/commit/?id={commit}"
244 ))
245 });
246
247 let mut dependencies = Vec::new();
248 let mut dep_count = 0;
249 'dep_loop: for dep in get_all(headers, "D") {
250 for dep_str in dep.split_whitespace() {
251 if dep_str.starts_with("so:") || dep_str.starts_with("cmd:") {
252 continue;
253 }
254
255 dep_count += 1;
256 if dep_count > MAX_ITERATION_COUNT {
257 warn!("Exceeded MAX_ITERATION_COUNT in dependency parsing, truncating");
258 break 'dep_loop;
259 }
260
261 let (dep_name, declared) = split_apk_dependency(dep_str);
262 dependencies.push(Dependency {
263 purl: crate::parsers::utils::namespaced_purl("apk", "alpine", dep_name, None),
264 extracted_requirement: declared.map(str::to_string),
265 scope: Some("install".to_string()),
266 is_runtime: Some(true),
267 is_optional: Some(false),
268 is_direct: Some(true),
269 resolved_package: None,
270 extra_data: None,
271 is_pinned: Some(false),
272 });
273 }
274 }
275
276 let mut extra_data = HashMap::new();
277
278 if is_virtual {
279 extra_data.insert("is_virtual".to_string(), true.into());
280 }
281
282 if let Some(checksum) = get_first(headers, "C") {
283 extra_data.insert("checksum".to_string(), checksum.into());
284 }
285
286 if let Some(size) = get_first(headers, "S") {
287 extra_data.insert("compressed_size".to_string(), size.into());
288 }
289
290 if let Some(installed_size) = get_first(headers, "I") {
291 extra_data.insert("installed_size".to_string(), installed_size.into());
292 }
293
294 if let Some(timestamp) = get_first(headers, "t") {
295 extra_data.insert("build_timestamp".to_string(), timestamp.into());
296 }
297
298 if let Some(commit) = get_first(headers, "c") {
299 extra_data.insert("git_commit".to_string(), commit.into());
300 }
301
302 let providers = extract_providers(raw_text);
303 if !providers.is_empty() {
304 let provider_list: Vec<serde_json::Value> =
305 providers.into_iter().map(|s| s.into()).collect();
306 extra_data.insert("providers".to_string(), provider_list.into());
307 }
308
309 let file_references = extract_file_references(raw_text);
310
311 PackageData {
312 datasource_id: Some(DatasourceId::AlpineInstalledDb),
313 package_type: Some(PACKAGE_TYPE),
314 namespace: namespace.clone(),
315 name: name.clone(),
316 version: version.clone(),
317 description,
318 homepage_url,
319 vcs_url,
320 parties,
321 declared_license_expression,
322 declared_license_expression_spdx,
323 license_detections,
324 extracted_license_statement,
325 source_packages,
326 dependencies,
327 file_references,
328 purl: name
329 .as_ref()
330 .and_then(|n| build_alpine_purl(n, version.as_deref(), architecture.as_deref())),
331 extra_data: if extra_data.is_empty() {
332 None
333 } else {
334 Some(extra_data)
335 },
336 ..Default::default()
337 }
338}
339
340fn parse_apkbuild(content: &str) -> PackageData {
341 let variables = parse_apkbuild_variables(content);
342
343 let name = variables
344 .get("pkgname")
345 .cloned()
346 .map(|value| strip_apkbuild_quote_chars(&value))
347 .map(truncate_field);
348 let version = match (variables.get("pkgver"), variables.get("pkgrel")) {
349 (Some(ver), Some(rel)) => Some(truncate_field(format!("{}-r{}", ver, rel))),
350 (Some(ver), None) => Some(truncate_field(ver.clone())),
351 _ => None,
352 };
353 let description = variables.get("pkgdesc").cloned().map(truncate_field);
354 let homepage_url = variables.get("url").cloned().map(truncate_field);
355 let extracted_license_statement = variables.get("license").cloned().map(truncate_field);
356 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
357 build_alpine_license_data(extracted_license_statement.as_deref());
358
359 let dependencies = parse_apkbuild_dependencies(&variables);
360
361 let mut extra_data = HashMap::new();
362 if let Some(source) = variables.get("source") {
363 let sources_value: Vec<serde_json::Value> = parse_apkbuild_sources(source)
364 .into_iter()
365 .map(|(file_name, url)| serde_json::json!({ "file_name": file_name, "url": url }))
366 .collect();
367 if !sources_value.is_empty() {
368 extra_data.insert(
369 "sources".to_string(),
370 serde_json::Value::Array(sources_value),
371 );
372 }
373 }
374 for (field, checksum_key) in [
375 ("sha512sums", "sha512"),
376 ("sha256sums", "sha256"),
377 ("md5sums", "md5"),
378 ] {
379 if let Some(checksums) = variables.get(field) {
380 let checksum_entries: Vec<serde_json::Value> = parse_apkbuild_checksums(checksums)
381 .into_iter()
382 .map(|(file_name, checksum)| serde_json::json!({ "file_name": file_name, checksum_key: checksum }))
383 .collect();
384 if !checksum_entries.is_empty() {
385 match extra_data.get_mut("checksums") {
386 Some(serde_json::Value::Array(existing)) => existing.extend(checksum_entries),
387 _ => {
388 extra_data.insert(
389 "checksums".to_string(),
390 serde_json::Value::Array(checksum_entries),
391 );
392 }
393 }
394 }
395 }
396 }
397
398 PackageData {
399 datasource_id: Some(DatasourceId::AlpineApkbuild),
400 package_type: Some(PACKAGE_TYPE),
401 namespace: None,
402 name: name.clone(),
403 version: version.clone(),
404 description,
405 homepage_url,
406 extracted_license_statement,
407 declared_license_expression,
408 declared_license_expression_spdx,
409 license_detections,
410 dependencies,
411 purl: name
412 .as_deref()
413 .and_then(|n| build_alpine_purl(n, version.as_deref(), None)),
414 extra_data: (!extra_data.is_empty()).then_some(extra_data),
415 ..default_package_data(DatasourceId::AlpineApkbuild)
416 }
417}
418
419const APKBUILD_CAPTURED_FIELDS: &[&str] = &[
420 "pkgname",
421 "pkgver",
422 "pkgrel",
423 "pkgdesc",
424 "url",
425 "license",
426 "source",
427 "depends",
428 "depends_dev",
429 "makedepends",
430 "makedepends_build",
431 "makedepends_host",
432 "checkdepends",
433 "sha512sums",
434 "sha256sums",
435 "md5sums",
436];
437
438fn parse_apkbuild_variables(content: &str) -> HashMap<String, String> {
439 let mut resolved_variables = HashMap::new();
440 let mut lines = content.lines().peekable();
441 let mut brace_depth = 0usize;
442 let mut line_count = 0usize;
443
444 while let Some(line) = lines.next() {
445 line_count += 1;
446 if line_count > MAX_ITERATION_COUNT {
447 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_variables, truncating");
448 break;
449 }
450 let trimmed = line.trim();
451 if trimmed.is_empty() || trimmed.starts_with('#') {
452 continue;
453 }
454 if trimmed.ends_with("(){") || trimmed.ends_with("() {") {
455 brace_depth += 1;
456 continue;
457 }
458 if brace_depth > 0 {
459 brace_depth += trimmed.chars().filter(|c| *c == '{').count();
460 brace_depth = brace_depth.saturating_sub(trimmed.chars().filter(|c| *c == '}').count());
461 continue;
462 }
463 let Some((name, value)) = trimmed.split_once('=') else {
464 continue;
465 };
466 let mut value = value.trim().to_string();
467 if starts_with_apkbuild_quote(&value) && !has_closed_apkbuild_quote(&value) {
468 while let Some(next) = lines.peek() {
469 value.push('\n');
470 value.push_str(next);
471 if lines.next().is_none() {
472 break;
473 }
474 if has_closed_apkbuild_quote(&value) {
475 break;
476 }
477 }
478 }
479 let name = name.trim().to_string();
480 if name == "pkgname" && resolved_variables.contains_key(name.as_str()) {
481 continue;
482 }
483 let value = strip_apkbuild_inline_comment(&value).trim();
484 let value = resolve_apkbuild_value(value, &resolved_variables);
485 if let Some(existing) = resolved_variables.get(&name)
486 && !existing.contains('$')
487 && value.contains('$')
488 {
489 continue;
490 }
491 resolved_variables.insert(name, value);
492 }
493
494 let mut resolved = HashMap::new();
495 for key in APKBUILD_CAPTURED_FIELDS {
496 if let Some(value) = resolved_variables.get(*key) {
497 resolved.insert(
498 (*key).to_string(),
499 resolve_apkbuild_value(value, &resolved_variables),
500 );
501 }
502 }
503 resolved
504}
505
506fn resolve_apkbuild_value(value: &str, variables: &HashMap<String, String>) -> String {
507 let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
508 if variables.is_empty() || !resolved.contains('$') {
509 return resolved;
510 }
511
512 for _ in 0..8 {
513 let mut changed = false;
514 changed |= replace_apkbuild_parameter_expressions(&mut resolved, variables);
515 for (name, raw_value) in variables {
516 let value_resolved = strip_wrapping_quotes(raw_value.trim());
517 changed |= replace_apkbuild_placeholder(
518 &mut resolved,
519 &format!("${{{name}//./-}}"),
520 &value_resolved.replace('.', "-"),
521 );
522 changed |= replace_apkbuild_placeholder(
523 &mut resolved,
524 &format!("${{{name}//./_}}"),
525 &value_resolved.replace('.', "_"),
526 );
527 changed |= replace_apkbuild_placeholder(
528 &mut resolved,
529 &format!("${{{name}::8}}"),
530 &value_resolved.chars().take(8).collect::<String>(),
531 );
532 changed |= replace_apkbuild_placeholder(
533 &mut resolved,
534 &format!("${{{name}}}"),
535 value_resolved,
536 );
537 }
538 changed |= replace_all_bare_apkbuild_variables(&mut resolved, variables);
539 if !changed || !resolved.contains('$') {
540 break;
541 }
542 }
543 resolved
544}
545
546fn replace_apkbuild_placeholder(
547 resolved: &mut String,
548 placeholder: &str,
549 replacement: &str,
550) -> bool {
551 if !resolved.contains(placeholder) {
552 return false;
553 }
554
555 *resolved = resolved.replace(placeholder, replacement);
556 true
557}
558
559fn replace_apkbuild_parameter_expressions(
560 resolved: &mut String,
561 variables: &HashMap<String, String>,
562) -> bool {
563 if !resolved.contains('$') {
564 return false;
565 }
566
567 let mut changed = false;
568 let mut output = String::with_capacity(resolved.len());
569 let mut rest = resolved.as_str();
570
571 while let Some(index) = rest.find('$') {
572 output.push_str(&rest[..index]);
573 rest = &rest[index..];
574
575 if let Some(stripped) = rest.strip_prefix("$(")
576 && let Some(expr) = stripped.strip_prefix('(')
577 && let Some(end) = expr.find("))")
578 && let Some(value) = evaluate_apkbuild_arithmetic_expression(&expr[..end], variables)
579 {
580 output.push_str(&value);
581 rest = &expr[end + 2..];
582 changed = true;
583 continue;
584 }
585
586 if let Some(expr) = rest.strip_prefix("${")
587 && let Some(end) = expr.find('}')
588 && let Some(value) = evaluate_apkbuild_parameter_expression(&expr[..end], variables)
589 {
590 output.push_str(&value);
591 rest = &expr[end + 1..];
592 changed = true;
593 continue;
594 }
595
596 output.push('$');
597 rest = &rest['$'.len_utf8()..];
598 }
599
600 if !changed {
601 return false;
602 }
603
604 output.push_str(rest);
605 *resolved = output;
606 true
607}
608
609fn evaluate_apkbuild_parameter_expression(
610 expr: &str,
611 variables: &HashMap<String, String>,
612) -> Option<String> {
613 if let Some((name, default)) = expr.split_once(":-") {
614 return Some(
615 variables
616 .get(name)
617 .filter(|value| !value.is_empty())
618 .cloned()
619 .unwrap_or_else(|| default.to_string()),
620 );
621 }
622
623 if let Some((name, pattern)) = expr.split_once("%%") {
624 let value = variables.get(name)?.as_str();
625 return trim_apkbuild_suffix_pattern(value, pattern, true);
626 }
627
628 if let Some((name, pattern)) = expr.split_once("##") {
629 let value = variables.get(name)?.as_str();
630 return trim_apkbuild_prefix_pattern(value, pattern, true);
631 }
632
633 if let Some((name, pattern)) = expr.split_once('%') {
634 let value = variables.get(name)?.as_str();
635 return trim_apkbuild_suffix_pattern(value, pattern, false);
636 }
637
638 if let Some((name, pattern)) = expr.split_once('#') {
639 let value = variables.get(name)?.as_str();
640 return trim_apkbuild_prefix_pattern(value, pattern, false);
641 }
642
643 if let Some((name, rest)) = expr.split_once("//") {
644 let (from, to) = rest.split_once('/').unwrap_or((rest, ""));
645 let value = variables.get(name)?.as_str();
646 return Some(value.replace(from, to));
647 }
648
649 if let Some((name, rest)) = expr.split_once('/') {
650 let (from, to) = rest.split_once('/')?;
651 let value = variables.get(name)?.as_str();
652 return Some(value.replacen(from, to, 1));
653 }
654
655 if let Some(name) = expr.strip_suffix("::8") {
656 let value = variables.get(name)?.as_str();
657 return Some(value.chars().take(8).collect());
658 }
659
660 Some(variables.get(expr)?.clone())
661}
662
663fn trim_apkbuild_suffix_pattern(value: &str, pattern: &str, longest: bool) -> Option<String> {
664 let matcher = pattern.strip_suffix('*')?;
665 let index = if let Some(class) = matcher.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
666 let chars: Vec<_> = class.chars().collect();
667 if longest {
668 value.char_indices().find(|(_, ch)| chars.contains(ch))?.0
669 } else {
670 value.char_indices().rfind(|(_, ch)| chars.contains(ch))?.0
671 }
672 } else if longest {
673 value.find(matcher)?
674 } else {
675 value.rfind(matcher)?
676 };
677
678 Some(value[..index].to_string())
679}
680
681fn trim_apkbuild_prefix_pattern(value: &str, pattern: &str, longest: bool) -> Option<String> {
682 let matcher = pattern.strip_prefix('*')?;
683 let index = if let Some(class) = matcher.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
684 let chars: Vec<_> = class.chars().collect();
685 let (idx, ch) = if longest {
686 value.char_indices().rfind(|(_, ch)| chars.contains(ch))?
687 } else {
688 value.char_indices().find(|(_, ch)| chars.contains(ch))?
689 };
690 idx + ch.len_utf8()
691 } else if longest {
692 value.rfind(matcher)? + matcher.len()
693 } else {
694 value.find(matcher)? + matcher.len()
695 };
696
697 Some(value[index..].to_string())
698}
699
700fn evaluate_apkbuild_arithmetic_expression(
701 expr: &str,
702 variables: &HashMap<String, String>,
703) -> Option<String> {
704 let mut total = 0i64;
705 let mut sign = 1i64;
706
707 for token in expr.split_whitespace() {
708 match token {
709 "+" => sign = 1,
710 "-" => sign = -1,
711 _ => {
712 let value = token
713 .parse::<i64>()
714 .ok()
715 .or_else(|| variables.get(token)?.parse::<i64>().ok())?;
716 total += sign * value;
717 }
718 }
719 }
720
721 Some(total.to_string())
722}
723
724fn replace_all_bare_apkbuild_variables(
725 resolved: &mut String,
726 variables: &HashMap<String, String>,
727) -> bool {
728 let mut changed = false;
729 let mut output = String::with_capacity(resolved.len());
730 let mut rest = resolved.as_str();
731
732 while let Some(index) = rest.find('$') {
733 output.push_str(&rest[..index]);
734 rest = &rest[index..];
735
736 if rest.starts_with("${") || rest.starts_with("$(") {
737 output.push('$');
738 rest = &rest['$'.len_utf8()..];
739 continue;
740 }
741
742 let Some(first) = rest[1..].chars().next() else {
743 output.push('$');
744 rest = &rest['$'.len_utf8()..];
745 continue;
746 };
747
748 if first == '_' || first.is_ascii_alphabetic() {
749 let mut name_len = first.len_utf8();
750 for ch in rest[1 + name_len..].chars() {
751 if ch == '_' || ch.is_ascii_alphanumeric() {
752 name_len += ch.len_utf8();
753 } else {
754 break;
755 }
756 }
757
758 let name = &rest[1..1 + name_len];
759 if let Some(value) = variables.get(name) {
760 output.push_str(value);
761 rest = &rest[1 + name_len..];
762 changed = true;
763 continue;
764 }
765 }
766
767 output.push('$');
768 rest = &rest['$'.len_utf8()..];
769 }
770
771 if !changed {
772 return false;
773 }
774
775 output.push_str(rest);
776 *resolved = output;
777 true
778}
779
780fn starts_with_apkbuild_quote(value: &str) -> bool {
781 matches!(value.trim_start().chars().next(), Some('"' | '\''))
782}
783
784fn has_closed_apkbuild_quote(value: &str) -> bool {
785 let trimmed = value.trim_start();
786 let Some(quote) = trimmed.chars().next().filter(|c| matches!(c, '"' | '\'')) else {
787 return true;
788 };
789
790 let mut escaped = false;
791 for ch in trimmed.chars().skip(1) {
792 if quote == '"' && escaped {
793 escaped = false;
794 continue;
795 }
796
797 if quote == '"' && ch == '\\' {
798 escaped = true;
799 continue;
800 }
801
802 if ch == quote {
803 return true;
804 }
805 }
806
807 false
808}
809
810fn strip_apkbuild_inline_comment(value: &str) -> &str {
811 let mut in_single = false;
812 let mut in_double = false;
813 let mut escaped = false;
814 let mut parameter_expansion_depth = 0usize;
815
816 let mut iter = value.char_indices().peekable();
817 while let Some((index, ch)) = iter.next() {
818 if escaped {
819 escaped = false;
820 continue;
821 }
822
823 match ch {
824 '$' if !in_single => {
825 if let Some((_, '{')) = iter.peek() {
826 parameter_expansion_depth += 1;
827 }
828 }
829 '\\' if in_double => escaped = true,
830 '\'' if !in_double => in_single = !in_single,
831 '"' if !in_single => in_double = !in_double,
832 '}' if parameter_expansion_depth > 0 && !in_single => {
833 parameter_expansion_depth -= 1;
834 }
835 '#' if !in_single && !in_double && parameter_expansion_depth == 0 => {
836 return value[..index].trim_end();
837 }
838 _ => {}
839 }
840 }
841
842 value.trim_end()
843}
844
845fn strip_apkbuild_quote_chars(value: &str) -> String {
846 value
847 .chars()
848 .filter(|ch| !matches!(ch, '"' | '\''))
849 .collect()
850}
851
852fn strip_wrapping_quotes(value: &str) -> &str {
853 value
854 .strip_prefix('"')
855 .and_then(|v| v.strip_suffix('"'))
856 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
857 .unwrap_or(value)
858}
859
860fn parse_apkbuild_sources(value: &str) -> Vec<(Option<String>, Option<String>)> {
861 value
862 .split_whitespace()
863 .filter(|part| !part.is_empty())
864 .map(|part| {
865 if let Some((file_name, url)) = part.split_once("::") {
866 (Some(file_name.to_string()), Some(url.to_string()))
867 } else if part.contains("://") {
868 (None, Some(part.to_string()))
869 } else {
870 (Some(part.to_string()), None)
871 }
872 })
873 .collect()
874}
875
876fn parse_apkbuild_checksums(value: &str) -> Vec<(String, String)> {
877 value
878 .lines()
879 .flat_map(|line| line.split_whitespace())
880 .collect::<Vec<_>>()
881 .chunks(2)
882 .filter_map(|chunk| {
883 if chunk.len() == 2 {
884 Some((chunk[1].to_string(), chunk[0].to_string()))
885 } else {
886 None
887 }
888 })
889 .collect()
890}
891
892fn split_apk_dependency(token: &str) -> (&str, Option<&str>) {
900 match token.find(['<', '>', '=', '~']) {
901 Some(name_end) => (&token[..name_end], Some(token)),
905 None => (token, None),
906 }
907}
908
909fn build_alpine_license_data(
910 extracted: Option<&str>,
911) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
912 let Some(extracted) = extracted.map(str::trim).filter(|s| !s.is_empty()) else {
913 return empty_declared_license_data();
914 };
915
916 if extracted == "custom:multiple" {
917 return build_declared_license_data_from_pair(
918 "unknown-license-reference",
919 "LicenseRef-scancode-unknown-license-reference",
920 DeclaredLicenseMatchMetadata::single_line(extracted),
921 );
922 }
923
924 let normalized_tokens = extracted
925 .split_whitespace()
926 .filter(|part| *part != "AND")
927 .map(normalize_alpine_license_token)
928 .collect::<Option<Vec<_>>>();
929
930 let Some(normalized_tokens) = normalized_tokens else {
931 return empty_declared_license_data();
932 };
933
934 let Some(combined) = combine_normalized_licenses(normalized_tokens, " AND ") else {
935 return empty_declared_license_data();
936 };
937
938 build_declared_license_data(
939 combined,
940 DeclaredLicenseMatchMetadata::single_line(extracted),
941 )
942}
943
944fn normalize_alpine_license_token(token: &str) -> Option<NormalizedDeclaredLicense> {
945 match token {
946 "ICU" => Some(NormalizedDeclaredLicense::new("x11", "ICU")),
947 "Unicode-TOU" => Some(NormalizedDeclaredLicense::new("unicode-tou", "Unicode-TOU")),
948 "Ruby" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
949 "BSD-2-Clause" => Some(NormalizedDeclaredLicense::new(
950 "bsd-simplified",
951 "BSD-2-Clause",
952 )),
953 "BSD-3-Clause" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
954 other => normalize_declared_license_key(other),
955 }
956}
957
958fn parse_apkbuild_dependencies(variables: &HashMap<String, String>) -> Vec<Dependency> {
959 let mut dependencies = Vec::new();
960 let mut dep_count = 0;
961
962 for (field, scope, is_runtime, is_optional) in [
963 ("depends", "depends", true, false),
964 ("depends_dev", "depends_dev", false, true),
965 ("makedepends", "makedepends", false, true),
966 ("makedepends_build", "makedepends_build", false, true),
967 ("makedepends_host", "makedepends_host", false, true),
968 ("checkdepends", "checkdepends", false, true),
969 ] {
970 let Some(value) = variables.get(field) else {
971 continue;
972 };
973
974 for dep_str in value.split_whitespace() {
975 let dep_str = dep_str.trim();
976 if dep_str.is_empty() {
977 continue;
978 }
979
980 dep_count += 1;
981 if dep_count > MAX_ITERATION_COUNT {
982 warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_dependencies, truncating");
983 return dependencies;
984 }
985
986 let dep_name = dep_str
987 .split(['<', '>', '=', '!', '~'])
988 .next()
989 .unwrap_or(dep_str)
990 .trim();
991 if dep_name.is_empty() || !is_static_apkbuild_dependency_name(dep_name) {
992 continue;
993 }
994
995 dependencies.push(Dependency {
996 purl: build_alpine_purl(dep_name, None, None),
997 extracted_requirement: Some(dep_str.to_string()),
998 scope: Some(scope.to_string()),
999 is_runtime: Some(is_runtime),
1000 is_optional: Some(is_optional),
1001 is_pinned: Some(dep_str.contains('=')),
1002 is_direct: Some(true),
1003 resolved_package: None,
1004 extra_data: None,
1005 });
1006 }
1007 }
1008
1009 dependencies
1010}
1011
1012fn is_static_apkbuild_dependency_name(dep_name: &str) -> bool {
1013 let mut chars = dep_name.chars();
1014 let Some(first) = chars.next() else {
1015 return false;
1016 };
1017
1018 if !first.is_ascii_alphanumeric() {
1019 return false;
1020 }
1021
1022 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
1023}
1024
1025fn extract_file_references(raw_text: &str) -> Vec<FileReference> {
1026 let mut file_references = Vec::new();
1027 let mut current_dir = String::new();
1028 let mut current_file: Option<FileReference> = None;
1029
1030 for line in raw_text.lines().capped("alpine file-reference lines") {
1031 if line.is_empty() {
1032 continue;
1033 }
1034
1035 if let Some((field_type, value)) = line.split_once(':') {
1036 let value = value.trim();
1037 match field_type {
1038 "F" => {
1039 if let Some(file) = current_file.take() {
1040 file_references.push(file);
1041 }
1042 current_dir = value.to_string();
1043 }
1044 "R" => {
1045 if let Some(file) = current_file.take() {
1046 file_references.push(file);
1047 }
1048
1049 let path = if current_dir.is_empty() {
1050 value.to_string()
1051 } else {
1052 format!("{}/{}", current_dir, value)
1053 };
1054
1055 current_file = Some(FileReference {
1056 path,
1057 size: None,
1058 sha1: None,
1059 md5: None,
1060 sha256: None,
1061 sha512: None,
1062 extra_data: None,
1063 });
1064 }
1065 "Z" => {
1066 if let Some(ref mut file) = current_file
1067 && value.starts_with("Q1")
1068 {
1069 use base64::Engine;
1070 if let Ok(decoded) =
1071 base64::engine::general_purpose::STANDARD.decode(&value[2..])
1072 && let Ok(digest) = Sha1Digest::from_hex(
1073 &decoded
1074 .iter()
1075 .map(|b| format!("{:02x}", b))
1076 .collect::<String>(),
1077 )
1078 {
1079 file.sha1 = Some(digest);
1080 }
1081 }
1082 }
1083 "a" => {
1084 if let Some(ref mut file) = current_file {
1085 let mut extra = HashMap::new();
1086 extra.insert(
1087 "attributes".to_string(),
1088 serde_json::Value::String(value.to_string()),
1089 );
1090 file.extra_data = Some(extra);
1091 }
1092 }
1093 _ => {}
1094 }
1095 }
1096 }
1097
1098 if let Some(file) = current_file {
1099 file_references.push(file);
1100 }
1101
1102 file_references
1103}
1104
1105fn extract_providers(raw_text: &str) -> Vec<String> {
1106 let mut providers = Vec::new();
1107
1108 for line in raw_text.lines().capped("alpine provider lines") {
1109 if line.is_empty() {
1110 continue;
1111 }
1112
1113 if let Some(value) = line.strip_prefix("p:") {
1114 providers.extend(value.split_whitespace().map(|s| s.to_string()));
1115 }
1116 }
1117
1118 providers
1119}
1120
1121fn build_alpine_purl(
1122 name: &str,
1123 version: Option<&str>,
1124 architecture: Option<&str>,
1125) -> Option<String> {
1126 use packageurl::PackageUrl;
1127
1128 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
1131 purl.with_namespace(ALPINE_VENDOR_NAMESPACE).ok()?;
1132
1133 if let Some(ver) = version {
1134 purl.with_version(ver).ok()?;
1135 }
1136
1137 if let Some(arch) = architecture {
1138 purl.add_qualifier("arch", arch).ok()?;
1139 }
1140
1141 Some(purl.to_string())
1142}
1143
1144pub struct AlpineApkParser;
1146
1147impl PackageParser for AlpineApkParser {
1148 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1149
1150 fn metadata() -> Vec<ParserMetadata> {
1151 vec![ParserMetadata {
1152 description: "Alpine Linux package (installed db and .apk archive)",
1153 file_patterns: &["**/lib/apk/db/installed", "**/*.apk"],
1154 package_type: "apk",
1155 primary_language: "",
1156 documentation_url: Some(
1157 "https://github.com/alpinelinux/apk-tools/blob/master/doc/apk-v2.5.scd",
1158 ),
1159 }]
1160 }
1161
1162 fn is_match(path: &Path) -> bool {
1163 path.extension().and_then(|e| e.to_str()) == Some("apk")
1164 && magic::is_gzip(path)
1165 && !magic::is_zip(path)
1166 && apk_contains_pkginfo(path)
1167 }
1168
1169 fn extract_packages(path: &Path) -> Vec<PackageData> {
1170 vec![match extract_apk_archive(path) {
1171 Ok(data) => data,
1172 Err(e) => {
1173 warn!("Failed to extract .apk archive {:?}: {}", path, e);
1174 PackageData {
1175 package_type: Some(PACKAGE_TYPE),
1176 datasource_id: Some(DatasourceId::AlpineApkArchive),
1177 ..Default::default()
1178 }
1179 }
1180 }]
1181 }
1182}
1183
1184fn apk_contains_pkginfo(path: &Path) -> bool {
1185 let archive_size = match std::fs::metadata(path) {
1186 Ok(m) => m.len(),
1187 Err(_) => return false,
1188 };
1189
1190 if archive_size > MAX_ARCHIVE_SIZE {
1191 warn!(
1192 "Archive {:?} exceeds MAX_ARCHIVE_SIZE ({} bytes)",
1193 path, archive_size
1194 );
1195 return false;
1196 }
1197
1198 apk_pkginfo_content(path, archive_size)
1199 .map(|content| content.is_some())
1200 .unwrap_or(false)
1201}
1202
1203fn extract_apk_archive(path: &Path) -> Result<PackageData, String> {
1204 let archive_size = std::fs::metadata(path)
1205 .map_err(|e| format!("Failed to stat .apk file: {}", e))?
1206 .len();
1207
1208 if archive_size > MAX_ARCHIVE_SIZE {
1209 return Err(format!(
1210 "Archive {:?} is {} bytes, exceeding MAX_ARCHIVE_SIZE ({} bytes)",
1211 path, archive_size, MAX_ARCHIVE_SIZE
1212 ));
1213 }
1214
1215 let content = apk_pkginfo_content(path, archive_size)?
1216 .ok_or_else(|| ".apk archive does not contain .PKGINFO file".to_string())?;
1217
1218 Ok(parse_pkginfo(&content))
1219}
1220
1221fn apk_pkginfo_content(path: &Path, archive_size: u64) -> Result<Option<String>, String> {
1222 use flate2::read::MultiGzDecoder;
1223 use std::io::Read;
1224
1225 let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .apk file: {}", e))?;
1226 let mut decoder = MultiGzDecoder::new(file);
1227 let mut decompressed = Vec::new();
1228 decoder
1229 .read_to_end(&mut decompressed)
1230 .map_err(|e| format!("Failed to decompress .apk archive: {}", e))?;
1231
1232 if decompressed.len() as u64 > MAX_ARCHIVE_SIZE {
1233 return Err(format!("Total extracted size exceeds limit for {:?}", path));
1234 }
1235
1236 let mut offset = 0usize;
1237 while offset + 512 <= decompressed.len() {
1238 let header = &decompressed[offset..offset + 512];
1239 if header.iter().all(|b| *b == 0) {
1240 offset += 512;
1241 continue;
1242 }
1243
1244 let name_end = header[..100].iter().position(|b| *b == 0).unwrap_or(100);
1245 let entry_name = String::from_utf8_lossy(&header[..name_end]);
1246 if entry_name.contains("..") {
1247 warn!("Skipping tar entry with path traversal: {}", entry_name);
1248 offset += 512;
1249 continue;
1250 }
1251
1252 let size_field = &header[124..136];
1253 let size_text = String::from_utf8_lossy(size_field).into_owned();
1254 let size_text = size_text.trim_matches(char::from(0)).trim();
1255 let size = usize::from_str_radix(size_text, 8)
1256 .map_err(|e| format!("Failed to parse tar entry size for {:?}: {}", path, e))?;
1257
1258 if size as u64 > MAX_FILE_SIZE {
1259 warn!(
1260 "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
1261 entry_name, path, size
1262 );
1263 offset += 512 + size.div_ceil(512) * 512;
1264 continue;
1265 }
1266
1267 if archive_size > 0 {
1268 let ratio = size as f64 / archive_size as f64;
1269 if ratio > MAX_COMPRESSION_RATIO {
1270 warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
1271 offset += 512 + size.div_ceil(512) * 512;
1272 continue;
1273 }
1274 }
1275
1276 let data_start = offset + 512;
1277 let data_end = data_start + size;
1278 if data_end > decompressed.len() {
1279 return Err(format!(
1280 "Tar entry {:?} exceeds decompressed archive size",
1281 entry_name
1282 ));
1283 }
1284
1285 if entry_name.ends_with(".PKGINFO") {
1286 let content = String::from_utf8(decompressed[data_start..data_end].to_vec())
1287 .map_err(|e| format!("Failed to decode .PKGINFO as UTF-8: {}", e))?;
1288 return Ok(Some(content));
1289 }
1290
1291 offset = data_start + size.div_ceil(512) * 512;
1292 }
1293
1294 Ok(None)
1295}
1296
1297fn parse_pkginfo(content: &str) -> PackageData {
1298 let mut fields: HashMap<&str, Vec<&str>> = HashMap::new();
1299
1300 for line in content.lines().capped("alpine .PKGINFO lines") {
1301 let line = line.trim();
1302 if line.is_empty() || line.starts_with('#') {
1303 continue;
1304 }
1305
1306 if let Some((key, value)) = line.split_once(" = ") {
1307 fields.entry(key.trim()).or_default().push(value.trim());
1308 }
1309 }
1310
1311 let name = fields
1312 .get("pkgname")
1313 .and_then(|v| v.first())
1314 .map(|s| truncate_field(s.to_string()));
1315 let pkgver = fields.get("pkgver").and_then(|v| v.first());
1316 let version = pkgver.map(|s| truncate_field(s.to_string()));
1317 let arch = fields
1318 .get("arch")
1319 .and_then(|v| v.first())
1320 .map(|s| truncate_field(s.to_string()));
1321 let license = fields
1322 .get("license")
1323 .and_then(|v| v.first())
1324 .map(|s| truncate_field(s.to_string()));
1325 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
1326 build_alpine_license_data(license.as_deref());
1327 let description = fields
1328 .get("pkgdesc")
1329 .and_then(|v| v.first())
1330 .map(|s| truncate_field(s.to_string()));
1331 let homepage = fields
1332 .get("url")
1333 .and_then(|v| v.first())
1334 .map(|s| truncate_field(s.to_string()));
1335 let origin = fields
1336 .get("origin")
1337 .and_then(|v| v.first())
1338 .map(|s| truncate_field(s.to_string()));
1339 let maintainer_str = fields.get("maintainer").and_then(|v| v.first());
1340
1341 let mut parties = Vec::new();
1342 if let Some(maint) = maintainer_str {
1343 let (maint_name, maint_email) = split_name_email(maint);
1344 parties.push(Party {
1345 r#type: Some(PartyType::Person),
1346 role: Some("maintainer".to_string()),
1347 name: maint_name,
1348 email: maint_email,
1349 url: None,
1350 organization: None,
1351 organization_url: None,
1352 timezone: None,
1353 });
1354 }
1355
1356 let purl = name
1357 .as_ref()
1358 .and_then(|n| build_alpine_purl(n, version.as_deref(), arch.as_deref()));
1359
1360 let mut dependencies = Vec::new();
1361 if let Some(depends_list) = fields.get("depend") {
1362 for (i, dep_str) in depends_list.iter().enumerate() {
1363 if i >= MAX_ITERATION_COUNT {
1364 warn!("Exceeded MAX_ITERATION_COUNT in parse_pkginfo dependencies, truncating");
1365 break;
1366 }
1367 let dep_name = dep_str.split_whitespace().next().unwrap_or(dep_str);
1368 let (dep_name, _) = split_apk_dependency(dep_name);
1369 dependencies.push(Dependency {
1370 purl: crate::parsers::utils::namespaced_purl("apk", "alpine", dep_name, None),
1371 extracted_requirement: Some(dep_str.to_string()),
1372 scope: Some("runtime".to_string()),
1373 is_runtime: Some(true),
1374 is_optional: Some(false),
1375 is_pinned: None,
1376 is_direct: Some(true),
1377 resolved_package: None,
1378 extra_data: None,
1379 });
1380 }
1381 }
1382
1383 PackageData {
1384 datasource_id: Some(DatasourceId::AlpineApkArchive),
1385 package_type: Some(PACKAGE_TYPE),
1386 namespace: Some("alpine".to_string()),
1387 name,
1388 version,
1389 description,
1390 homepage_url: homepage,
1391 declared_license_expression,
1392 declared_license_expression_spdx,
1393 license_detections,
1394 extracted_license_statement: license,
1395 parties,
1396 dependencies,
1397 purl,
1398 extra_data: origin.map(|o| {
1399 let mut map = HashMap::new();
1400 map.insert("origin".to_string(), serde_json::Value::String(o));
1401 map
1402 }),
1403 ..Default::default()
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use std::io::Write;
1411 use std::path::PathBuf;
1412 use tempfile::TempDir;
1413
1414 #[test]
1415 fn test_apk_dependency_constraints_stay_out_of_the_purl_name() {
1416 assert_eq!(
1421 split_apk_dependency("musl>=1.2.0"),
1422 ("musl", Some("musl>=1.2.0"))
1423 );
1424 assert_eq!(
1425 split_apk_dependency("busybox=1.36.1-r5"),
1426 ("busybox", Some("busybox=1.36.1-r5"))
1427 );
1428 assert_eq!(split_apk_dependency("zlib~1.3"), ("zlib", Some("zlib~1.3")));
1429 assert_eq!(split_apk_dependency("musl"), ("musl", None));
1431
1432 let purl = crate::parsers::utils::namespaced_purl("apk", "alpine", "musl", None)
1433 .expect("purl should build");
1434 assert_eq!(purl, "pkg:apk/alpine/musl");
1435 }
1436
1437 fn create_temp_installed_db(content: &str) -> (TempDir, PathBuf) {
1440 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1441 let db_dir = temp_dir.path().join("lib/apk/db");
1442 std::fs::create_dir_all(&db_dir).expect("Failed to create db dir");
1443 let file_path = db_dir.join("installed");
1444 let mut file = std::fs::File::create(&file_path).expect("Failed to create file");
1445 file.write_all(content.as_bytes())
1446 .expect("Failed to write content");
1447 (temp_dir, file_path)
1448 }
1449
1450 #[test]
1451 fn test_alpine_parser_is_match() {
1452 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1453 "/lib/apk/db/installed"
1454 )));
1455 assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1456 "/var/lib/apk/db/installed"
1457 )));
1458 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1459 "/lib/apk/db/status"
1460 )));
1461 assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1462 "installed"
1463 )));
1464 }
1465
1466 #[test]
1467 fn test_parse_alpine_package_basic() {
1468 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1469P:alpine-baselayout-data
1470V:3.2.0-r22
1471A:x86_64
1472S:11435
1473I:73728
1474T:Alpine base dir structure and init scripts
1475U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
1476L:GPL-2.0-only
1477o:alpine-baselayout
1478m:Natanael Copa <ncopa@alpinelinux.org>
1479t:1655134784
1480c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1481
1482";
1483 let (_dir, path) = create_temp_installed_db(content);
1484 let pkg = AlpineInstalledParser::extract_first_package(&path);
1485 assert_eq!(pkg.name, Some("alpine-baselayout-data".to_string()));
1486 assert_eq!(pkg.version, Some("3.2.0-r22".to_string()));
1487 assert_eq!(pkg.namespace, Some("alpine".to_string()));
1488 assert_eq!(
1489 pkg.description,
1490 Some("Alpine base dir structure and init scripts".to_string())
1491 );
1492 assert_eq!(
1493 pkg.homepage_url,
1494 Some("https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout".to_string())
1495 );
1496 assert_eq!(
1497 pkg.extracted_license_statement,
1498 Some("GPL-2.0-only".to_string())
1499 );
1500 assert_eq!(pkg.parties.len(), 1);
1501 assert_eq!(pkg.parties[0].name, Some("Natanael Copa".to_string()));
1502 assert_eq!(
1503 pkg.parties[0].email,
1504 Some("ncopa@alpinelinux.org".to_string())
1505 );
1506 assert!(
1507 pkg.purl
1508 .as_ref()
1509 .unwrap()
1510 .contains("alpine-baselayout-data")
1511 );
1512 assert!(pkg.purl.as_ref().unwrap().contains("arch=x86_64"));
1513 }
1514
1515 #[test]
1516 fn test_parse_alpine_with_dependencies() {
1517 let content = "P:musl
1518V:1.2.3-r0
1519A:x86_64
1520D:scanelf so:libc.musl-x86_64.so.1
1521
1522";
1523 let (_dir, path) = create_temp_installed_db(content);
1524 let pkg = AlpineInstalledParser::extract_first_package(&path);
1525 assert_eq!(pkg.name, Some("musl".to_string()));
1526 assert_eq!(pkg.dependencies.len(), 1);
1527 assert!(
1528 pkg.dependencies[0]
1529 .purl
1530 .as_ref()
1531 .unwrap()
1532 .contains("scanelf")
1533 );
1534 }
1535
1536 #[test]
1537 fn test_build_alpine_purl() {
1538 let purl = build_alpine_purl("busybox", Some("1.31.1-r9"), Some("x86_64"));
1539 assert_eq!(
1540 purl,
1541 Some("pkg:apk/alpine/busybox@1.31.1-r9?arch=x86_64".to_string())
1542 );
1543
1544 let purl_no_arch = build_alpine_purl("package", Some("1.0"), None);
1545 assert_eq!(purl_no_arch, Some("pkg:apk/alpine/package@1.0".to_string()));
1546 }
1547
1548 #[test]
1549 fn test_parse_alpine_extra_data() {
1550 let content = "P:test-package
1551V:1.0
1552C:base64checksum==
1553S:12345
1554I:67890
1555t:1234567890
1556c:gitcommithash
1557
1558";
1559 let (_dir, path) = create_temp_installed_db(content);
1560 let pkg = AlpineInstalledParser::extract_first_package(&path);
1561 assert!(pkg.extra_data.is_some());
1562 let extra = pkg.extra_data.as_ref().unwrap();
1563 assert_eq!(extra["checksum"], "base64checksum==");
1564 assert_eq!(extra["compressed_size"], "12345");
1565 assert_eq!(extra["installed_size"], "67890");
1566 assert_eq!(extra["build_timestamp"], "1234567890");
1567 assert_eq!(extra["git_commit"], "gitcommithash");
1568 }
1569
1570 #[test]
1571 fn test_parse_alpine_case_sensitive_keys() {
1572 let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1573P:test-pkg
1574V:1.0
1575T:A test description
1576t:1655134784
1577c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1578
1579";
1580 let (_dir, path) = create_temp_installed_db(content);
1581 let pkg = AlpineInstalledParser::extract_first_package(&path);
1582 assert_eq!(pkg.description, Some("A test description".to_string()));
1583 let extra = pkg.extra_data.as_ref().unwrap();
1584 assert_eq!(extra["checksum"], "Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=");
1585 assert_eq!(extra["build_timestamp"], "1655134784");
1586 assert_eq!(
1587 extra["git_commit"],
1588 "cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1589 );
1590 }
1591
1592 #[test]
1593 fn test_parse_alpine_multiple_packages() {
1594 let content = "P:package1
1595V:1.0
1596A:x86_64
1597
1598P:package2
1599V:2.0
1600A:aarch64
1601
1602";
1603 let (_dir, path) = create_temp_installed_db(content);
1604 let pkgs = AlpineInstalledParser::extract_packages(&path);
1605 assert_eq!(pkgs.len(), 2);
1606 assert_eq!(pkgs[0].name, Some("package1".to_string()));
1607 assert_eq!(pkgs[0].version, Some("1.0".to_string()));
1608 assert_eq!(pkgs[1].name, Some("package2".to_string()));
1609 assert_eq!(pkgs[1].version, Some("2.0".to_string()));
1610 }
1611
1612 #[test]
1613 fn test_parse_alpine_file_references() {
1614 let content = "P:test-pkg
1615V:1.0
1616F:usr/bin
1617R:test
1618Z:Q1WTc55xfvPogzA0YUV24D0Ym+MKE=
1619F:etc
1620R:config
1621Z:Q1pcfTfDNEbNKQc2s1tia7da05M8Q=
1622
1623";
1624 let (_dir, path) = create_temp_installed_db(content);
1625 let pkg = AlpineInstalledParser::extract_first_package(&path);
1626 assert_eq!(pkg.file_references.len(), 2);
1627 assert_eq!(pkg.file_references[0].path, "usr/bin/test");
1628 assert!(pkg.file_references[0].sha1.is_some());
1629 assert_eq!(pkg.file_references[1].path, "etc/config");
1630 assert!(pkg.file_references[1].sha1.is_some());
1631 }
1632
1633 #[test]
1634 fn test_parse_alpine_empty_fields() {
1635 let content = "P:minimal-package
1636V:1.0
1637
1638";
1639 let (_dir, path) = create_temp_installed_db(content);
1640 let pkg = AlpineInstalledParser::extract_first_package(&path);
1641 assert_eq!(pkg.name, Some("minimal-package".to_string()));
1642 assert_eq!(pkg.version, Some("1.0".to_string()));
1643 assert!(pkg.description.is_none());
1644 assert!(pkg.homepage_url.is_none());
1645 assert_eq!(pkg.dependencies.len(), 0);
1646 }
1647
1648 #[test]
1649 fn test_parse_alpine_origin_field() {
1650 let content = "P:busybox-ifupdown
1651V:1.35.0-r13
1652o:busybox
1653A:x86_64
1654
1655";
1656 let (_dir, path) = create_temp_installed_db(content);
1657 let pkg = AlpineInstalledParser::extract_first_package(&path);
1658 assert_eq!(pkg.name, Some("busybox-ifupdown".to_string()));
1659 assert_eq!(pkg.source_packages.len(), 1);
1660 assert_eq!(pkg.source_packages[0], "pkg:apk/alpine/busybox");
1661 }
1662
1663 #[test]
1664 fn test_parse_alpine_url_field() {
1665 let content = "P:openssl
1666V:1.1.1q-r0
1667U:https://www.openssl.org
1668A:x86_64
1669
1670";
1671 let (_dir, path) = create_temp_installed_db(content);
1672 let pkg = AlpineInstalledParser::extract_first_package(&path);
1673 assert_eq!(
1674 pkg.homepage_url,
1675 Some("https://www.openssl.org".to_string())
1676 );
1677 }
1678
1679 #[test]
1680 fn test_parse_alpine_provider_field() {
1681 let content = "P:some-package
1682V:1.0
1683p:cmd:binary=1.0
1684p:so:libtest.so.1
1685
1686";
1687 let (_dir, path) = create_temp_installed_db(content);
1688 let pkg = AlpineInstalledParser::extract_first_package(&path);
1689 assert!(pkg.extra_data.is_some());
1690 let extra = pkg.extra_data.as_ref().unwrap();
1691 let providers = extra.get("providers").and_then(|v| v.as_array());
1692 assert!(providers.is_some());
1693 let provider_array = providers.unwrap();
1694 assert_eq!(provider_array.len(), 2);
1695 assert_eq!(provider_array[0].as_str(), Some("cmd:binary=1.0"));
1696 assert_eq!(provider_array[1].as_str(), Some("so:libtest.so.1"));
1697 }
1698
1699 #[test]
1700 fn test_alpine_apk_parser_is_match() {
1701 let apk_path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1702
1703 assert!(AlpineApkParser::is_match(&apk_path));
1704 assert!(!AlpineApkParser::is_match(&PathBuf::from("package.tar.gz")));
1705 assert!(!AlpineApkParser::is_match(&PathBuf::from("installed")));
1706 }
1707
1708 #[test]
1709 fn test_alpine_apk_parser_rejects_android_and_placeholder_apk_fixtures() {
1710 let android_apk = PathBuf::from("testdata/misc/test_android.apk");
1711 let placeholder_alpine_apk = PathBuf::from("testdata/misc/test_alpine.apk");
1712 let valid_alpine_apk = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1713
1714 assert!(!AlpineApkParser::is_match(&android_apk));
1715 assert!(!AlpineApkParser::is_match(&placeholder_alpine_apk));
1716 assert!(AlpineApkParser::is_match(&valid_alpine_apk));
1717 }
1718
1719 #[test]
1720 fn test_alpine_apk_parser_supports_concatenated_gzip_members() {
1721 use flate2::Compression;
1722 use flate2::write::GzEncoder;
1723 use std::io::Write;
1724 use tar::{Builder, Header};
1725
1726 fn gzip_tar_member(path: &str, contents: &[u8]) -> Vec<u8> {
1727 let encoder = GzEncoder::new(Vec::new(), Compression::default());
1728 let mut builder = Builder::new(encoder);
1729 let mut header = Header::new_gnu();
1730 header.set_size(contents.len() as u64);
1731 header.set_mode(0o644);
1732 header.set_cksum();
1733 builder
1734 .append_data(&mut header, path, contents)
1735 .expect("append tar entry");
1736 let encoder = builder.into_inner().expect("finish tar builder");
1737 encoder.finish().expect("finish gzip encoder")
1738 }
1739
1740 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
1741 let apk_path = temp_dir.path().join("synthetic.apk");
1742
1743 let signature_member = gzip_tar_member(
1744 ".SIGN.RSA.alpine-devel@lists.alpinelinux.org-test.rsa.pub",
1745 b"signature",
1746 );
1747 let pkginfo_member = gzip_tar_member(
1748 ".PKGINFO",
1749 b"pkgname = synthetic\npkgver = 1.0-r0\npkgdesc = Synthetic APK\nurl = https://example.com\nlicense = MIT\narch = x86_64\n",
1750 );
1751
1752 let mut file = std::fs::File::create(&apk_path).expect("create synthetic apk");
1753 file.write_all(&signature_member)
1754 .expect("write signature member");
1755 file.write_all(&pkginfo_member)
1756 .expect("write pkginfo member");
1757
1758 assert!(AlpineApkParser::is_match(&apk_path));
1759 let pkg = AlpineApkParser::extract_first_package(&apk_path);
1760 assert_eq!(pkg.name.as_deref(), Some("synthetic"));
1761 assert_eq!(pkg.version.as_deref(), Some("1.0-r0"));
1762 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1763 }
1764
1765 #[test]
1766 fn test_alpine_apkbuild_parser_is_match() {
1767 assert!(AlpineApkbuildParser::is_match(&PathBuf::from("APKBUILD")));
1768 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1769 "/path/to/APKBUILD"
1770 )));
1771 assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1772 "linux-firmware-APKBUILD"
1773 )));
1774 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from("apkbuild")));
1775 assert!(!AlpineApkbuildParser::is_match(&PathBuf::from(
1776 "APKBUILD.txt"
1777 )));
1778 }
1779
1780 #[test]
1781 fn test_parse_apkbuild_icu_reference() {
1782 let path = PathBuf::from("testdata/alpine-fixtures/apkbuild/alpine14/main/icu/APKBUILD");
1783 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1784
1785 assert_eq!(pkg.datasource_id, Some(DatasourceId::AlpineApkbuild));
1786 assert_eq!(pkg.name.as_deref(), Some("icu"));
1787 assert_eq!(pkg.version.as_deref(), Some("67.1-r2"));
1788 assert_eq!(
1789 pkg.description.as_deref(),
1790 Some("International Components for Unicode library")
1791 );
1792 assert_eq!(
1793 pkg.homepage_url.as_deref(),
1794 Some("http://site.icu-project.org/")
1795 );
1796 assert_eq!(
1797 pkg.extracted_license_statement.as_deref(),
1798 Some("MIT ICU Unicode-TOU")
1799 );
1800 assert_eq!(
1801 pkg.declared_license_expression_spdx.as_deref(),
1802 Some("ICU AND MIT AND Unicode-TOU")
1803 );
1804 assert_eq!(pkg.dependencies.len(), 3);
1805 let depends_dev = pkg
1806 .dependencies
1807 .iter()
1808 .find(|dep| dep.scope.as_deref() == Some("depends_dev"))
1809 .expect("depends_dev dependency missing");
1810 assert_eq!(depends_dev.purl.as_deref(), Some("pkg:apk/alpine/icu"));
1811 assert_eq!(depends_dev.is_runtime, Some(false));
1812 assert_eq!(depends_dev.is_optional, Some(true));
1813
1814 let check_dep_names: Vec<_> = pkg
1815 .dependencies
1816 .iter()
1817 .filter(|dep| dep.scope.as_deref() == Some("checkdepends"))
1818 .filter_map(|dep| dep.purl.as_deref())
1819 .collect();
1820 assert!(check_dep_names.contains(&"pkg:apk/alpine/diffutils"));
1821 assert!(check_dep_names.contains(&"pkg:apk/alpine/python3"));
1822 let extra = pkg.extra_data.as_ref().unwrap();
1823 assert!(extra.contains_key("sources"));
1824 assert!(extra.contains_key("checksums"));
1825 }
1826
1827 #[test]
1828 fn test_parse_apkbuild_custom_multiple_license_uses_raw_matched_text() {
1829 let path = PathBuf::from(
1830 "testdata/alpine-fixtures/apkbuild/alpine13/main/linux-firmware/APKBUILD",
1831 );
1832 let pkg = AlpineApkbuildParser::extract_first_package(&path);
1833
1834 assert_eq!(pkg.name.as_deref(), Some("linux-firmware"));
1835 assert_eq!(pkg.version.as_deref(), Some("20201218-r0"));
1836 assert_eq!(
1837 pkg.extracted_license_statement.as_deref(),
1838 Some("custom:multiple")
1839 );
1840 assert_eq!(
1841 pkg.declared_license_expression.as_deref(),
1842 Some("unknown-license-reference")
1843 );
1844 assert_eq!(
1845 pkg.declared_license_expression_spdx.as_deref(),
1846 Some("LicenseRef-scancode-unknown-license-reference")
1847 );
1848 let matched = pkg.license_detections[0].matches[0].matched_text.as_deref();
1849 assert_eq!(matched, Some("custom:multiple"));
1850 }
1851
1852 #[test]
1853 fn test_parse_apkbuild_self_referential_makedepends_uses_previous_values() {
1854 let content = r#"
1855pkgname=util-linux
1856pkgver=2.41.4
1857pkgrel=0
1858makedepends_build="bash"
1859makedepends_host="
1860 libcap-ng-dev
1861 linux-headers
1862 "
1863if [ -z "$BOOTSTRAP" ]; then
1864 makedepends_build="$makedepends_build asciidoctor"
1865 makedepends_host="$makedepends_host python3-dev"
1866fi
1867makedepends="$makedepends_build $makedepends_host"
1868"#;
1869
1870 let variables = parse_apkbuild_variables(content);
1871
1872 assert_eq!(
1873 variables.get("makedepends_build").map(String::as_str),
1874 Some("bash asciidoctor")
1875 );
1876 let makedepends_host = variables
1877 .get("makedepends_host")
1878 .expect("makedepends_host should resolve");
1879 assert!(makedepends_host.contains("libcap-ng-dev"));
1880 assert!(makedepends_host.contains("linux-headers"));
1881 assert!(makedepends_host.contains("python3-dev"));
1882 assert!(!makedepends_host.contains("$makedepends_host"));
1883
1884 let makedepends = variables
1885 .get("makedepends")
1886 .expect("makedepends should resolve");
1887 assert!(makedepends.contains("bash asciidoctor"));
1888 assert!(makedepends.contains("libcap-ng-dev"));
1889 assert!(makedepends.contains("linux-headers"));
1890 assert!(makedepends.contains("python3-dev"));
1891 assert!(!makedepends.contains("$makedepends_build"));
1892 assert!(!makedepends.contains("$makedepends_host"));
1893 }
1894
1895 #[test]
1896 fn test_parse_apkbuild_skips_unresolved_shell_fragments_in_dependencies() {
1897 let content = r#"
1898pkgname=test
1899pkgver=1.0
1900pkgrel=0
1901makedepends="$makedepends_build ${_target/./_} openjdk$_jdkbuild-jdk bash %22 aarch64)"
1902"#;
1903
1904 let pkg = parse_apkbuild(content);
1905 let dependency_purls: Vec<_> = pkg
1906 .dependencies
1907 .iter()
1908 .filter_map(|dep| dep.purl.as_deref())
1909 .collect();
1910
1911 assert_eq!(dependency_purls, vec!["pkg:apk/alpine/bash"]);
1912 }
1913
1914 #[test]
1915 fn test_parse_apkbuild_ignores_inline_comments_after_dependency_values() {
1916 let content = r#"
1917pkgname=bat
1918pkgver=0.26.1
1919pkgrel=0
1920depends="less" # Required for RAW-CONTROL-CHARS
1921makedepends="e2fsprogs-dev" # is pulled in externally.
1922checkdepends="bash"
1923"#;
1924
1925 let pkg = parse_apkbuild(content);
1926 let dependency_purls: Vec<_> = pkg
1927 .dependencies
1928 .iter()
1929 .filter_map(|dep| dep.purl.as_deref())
1930 .collect();
1931
1932 assert_eq!(
1933 dependency_purls,
1934 vec![
1935 "pkg:apk/alpine/less",
1936 "pkg:apk/alpine/e2fsprogs-dev",
1937 "pkg:apk/alpine/bash",
1938 ]
1939 );
1940 }
1941
1942 #[test]
1943 fn test_resolve_apkbuild_value_supports_common_parameter_expansions() {
1944 let variables = HashMap::from([
1945 ("_pkgver".to_string(), "1.6.0-641".to_string()),
1946 ("_iverilog".to_string(), "13_0".to_string()),
1947 ("pkgver".to_string(), "18.2.7".to_string()),
1948 ("_krel".to_string(), "0".to_string()),
1949 ("_rel".to_string(), "2".to_string()),
1950 ("FLAVOR".to_string(), "".to_string()),
1951 ]);
1952
1953 assert_eq!(
1954 resolve_apkbuild_value("${_pkgver/-/.}", &variables),
1955 "1.6.0.641"
1956 );
1957 assert_eq!(resolve_apkbuild_value("${pkgver%%.*}", &variables), "18");
1958 assert_eq!(resolve_apkbuild_value("${pkgver%.*}", &variables), "18.2");
1959 assert_eq!(resolve_apkbuild_value("${_iverilog##*_}", &variables), "0");
1960 assert_eq!(
1961 resolve_apkbuild_value("${_iverilog%%_*}.${_iverilog##*_}", &variables),
1962 "13.0"
1963 );
1964 assert_eq!(
1965 resolve_apkbuild_value("$(( _krel + _rel ))", &variables),
1966 "2"
1967 );
1968 assert_eq!(resolve_apkbuild_value("${FLAVOR:-lts}", &variables), "lts");
1969 }
1970
1971 #[test]
1972 fn test_parse_apkbuild_keeps_initial_package_identity_assignment() {
1973 let content = r#"
1974pkgname=go
1975pkgver=1.26.2
1976pkgrel=0
1977if [ "$CBUILD" != "$CHOST" ]; then
1978 pkgname="go-bootstrap"
1979 pkgrel=1
1980fi
1981"#;
1982
1983 let variables = parse_apkbuild_variables(content);
1984 assert_eq!(variables.get("pkgname").map(String::as_str), Some("go"));
1985 }
1986
1987 #[test]
1988 fn test_parse_apkbuild_strips_concatenated_shell_quotes_from_package_name() {
1989 let content = r#"
1990_pkgname=cinny
1991pkgname="$_pkgname"-web
1992pkgver=4.11.1
1993pkgrel=0
1994"#;
1995
1996 let pkg = parse_apkbuild(content);
1997 assert_eq!(pkg.name.as_deref(), Some("cinny-web"));
1998 }
1999
2000 #[test]
2001 fn test_parse_apkbuild_re_resolves_forward_references_in_package_identity() {
2002 let content = r#"
2003pkgname=ceph${pkgver%%.*}
2004pkgver=18.2.7
2005pkgrel=7
2006"#;
2007
2008 let pkg = parse_apkbuild(content);
2009 assert_eq!(pkg.name.as_deref(), Some("ceph18"));
2010 assert_eq!(pkg.version.as_deref(), Some("18.2.7-r7"));
2011 }
2012
2013 #[test]
2014 fn test_parse_apkbuild_supports_empty_global_replacement_in_pkgver() {
2015 let content = r#"
2016pkgname=quickjs
2017_pkgver=2025-09-13
2018pkgver=0.${_pkgver//-}
2019pkgrel=0
2020"#;
2021
2022 let pkg = parse_apkbuild(content);
2023 assert_eq!(pkg.version.as_deref(), Some("0.20250913-r0"));
2024 }
2025
2026 #[test]
2027 fn test_parse_apkbuild_supports_split_version_parts() {
2028 let content = r#"
2029pkgname=iverilog
2030_pkgver=13_0
2031pkgver=${_pkgver%%_*}.${_pkgver##*_}
2032pkgrel=0
2033"#;
2034
2035 let variables = parse_apkbuild_variables(content);
2036 assert_eq!(variables.get("pkgver").map(String::as_str), Some("13.0"));
2037
2038 let pkg = parse_apkbuild(content);
2039 assert_eq!(pkg.version.as_deref(), Some("13.0-r0"));
2040 }
2041
2042 #[test]
2043 fn test_parse_apkbuild_keeps_loop_assignments_from_blowing_up_dependencies() {
2044 let content = r#"
2045pkgname=alpine-ipxe
2046pkgver=1.20.1
2047pkgrel=2
2048makedepends="xz-dev perl coreutils bash"
2049_targets="bin/ipxe.iso bin/ipxe.lkrn"
2050for _target in $_targets; do
2051 _target=${_target##*/}
2052 _target=${_target/./_}
2053 subpackages="$subpackages $pkgname-$_target:_split"
2054done
2055"#;
2056
2057 let pkg = parse_apkbuild(content);
2058 let dependency_purls: Vec<_> = pkg
2059 .dependencies
2060 .iter()
2061 .filter_map(|dep| dep.purl.as_deref())
2062 .collect();
2063
2064 assert_eq!(
2065 dependency_purls,
2066 vec![
2067 "pkg:apk/alpine/xz-dev",
2068 "pkg:apk/alpine/perl",
2069 "pkg:apk/alpine/coreutils",
2070 "pkg:apk/alpine/bash",
2071 ]
2072 );
2073 }
2074
2075 #[test]
2076 fn test_parse_alpine_no_files_package_still_detected() {
2077 let path = PathBuf::from("testdata/alpine-fixtures/full-installed/installed");
2078 let content = std::fs::read_to_string(&path).expect("read installed db fixture");
2079 let packages = parse_alpine_installed_db(&content);
2080 let libc_utils = packages
2081 .into_iter()
2082 .find(|pkg| pkg.name.as_deref() == Some("libc-utils"))
2083 .expect("libc-utils package should exist");
2084
2085 assert_eq!(libc_utils.file_references.len(), 0);
2086 assert!(
2087 libc_utils
2088 .purl
2089 .as_deref()
2090 .is_some_and(|p| p.contains("libc-utils"))
2091 );
2092 }
2093
2094 #[test]
2095 fn test_parse_alpine_commit_generates_https_vcs_url() {
2096 let content =
2097 "P:test-package\nV:1.0-r0\nA:x86_64\nc:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd\n";
2098 let (_dir, path) = create_temp_installed_db(content);
2099 let pkg = AlpineInstalledParser::extract_first_package(&path);
2100
2101 assert_eq!(
2102 pkg.vcs_url.as_deref(),
2103 Some(
2104 "git+https://git.alpinelinux.org/aports/commit/?id=cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
2105 )
2106 );
2107 }
2108
2109 #[test]
2110 fn test_parse_alpine_virtual_package() {
2111 let content = "P:.postgis-rundeps
2112V:20210104.190748
2113A:noarch
2114S:0
2115I:0
2116T:virtual meta package
2117U:
2118L:
2119D:json-c geos gdal proj protobuf-c libstdc++
2120
2121";
2122 let (_dir, path) = create_temp_installed_db(content);
2123 let pkg = AlpineInstalledParser::extract_first_package(&path);
2124 assert_eq!(pkg.name, Some(".postgis-rundeps".to_string()));
2125 assert_eq!(pkg.version, Some("20210104.190748".to_string()));
2126 assert_eq!(pkg.description, Some("virtual meta package".to_string()));
2127 assert!(pkg.extra_data.is_some());
2128 let extra = pkg.extra_data.as_ref().unwrap();
2129 assert_eq!(
2130 extra.get("is_virtual").and_then(|v| v.as_bool()),
2131 Some(true)
2132 );
2133 assert_eq!(pkg.dependencies.len(), 6);
2134 assert!(pkg.homepage_url.is_none());
2135 assert!(pkg.extracted_license_statement.is_none());
2136 }
2137
2138 #[test]
2139 fn test_installed_db_license_normalization() {
2140 let content = "P:test-package\nV:1.0-r0\nA:x86_64\nL:MIT\n\n";
2141 let (_dir, path) = create_temp_installed_db(content);
2142 let pkg = AlpineInstalledParser::extract_first_package(&path);
2143
2144 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
2145 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
2146 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
2147 assert_eq!(pkg.license_detections.len(), 1);
2148 }
2149
2150 #[test]
2151 fn test_apk_archive_license_normalization() {
2152 let path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
2153 let pkg = AlpineApkParser::extract_first_package(&path);
2154
2155 assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
2156 assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
2157 assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
2158 assert_eq!(pkg.license_detections.len(), 1);
2159 }
2160}