Skip to main content

provenant/parsers/
alpine.rs

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