Skip to main content

provenant/parsers/
alpine.rs

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