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