Skip to main content

provenant/parsers/
alpine.rs

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