Skip to main content

provenant/parsers/
alpine.rs

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