Skip to main content

provenant/parsers/
alpine.rs

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