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