Skip to main content

provenant/parsers/
alpine.rs

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