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.get("pkgname").cloned().map(truncate_field);
316    let version = match (variables.get("pkgver"), variables.get("pkgrel")) {
317        (Some(ver), Some(rel)) => Some(truncate_field(format!("{}-r{}", ver, rel))),
318        (Some(ver), None) => Some(truncate_field(ver.clone())),
319        _ => None,
320    };
321    let description = variables.get("pkgdesc").cloned().map(truncate_field);
322    let homepage_url = variables.get("url").cloned().map(truncate_field);
323    let extracted_license_statement = variables.get("license").cloned().map(truncate_field);
324    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
325        build_alpine_license_data(extracted_license_statement.as_deref());
326
327    let dependencies = parse_apkbuild_dependencies(&variables);
328
329    let mut extra_data = HashMap::new();
330    if let Some(source) = variables.get("source") {
331        let sources_value: Vec<serde_json::Value> = parse_apkbuild_sources(source)
332            .into_iter()
333            .map(|(file_name, url)| serde_json::json!({ "file_name": file_name, "url": url }))
334            .collect();
335        if !sources_value.is_empty() {
336            extra_data.insert(
337                "sources".to_string(),
338                serde_json::Value::Array(sources_value),
339            );
340        }
341    }
342    for (field, checksum_key) in [
343        ("sha512sums", "sha512"),
344        ("sha256sums", "sha256"),
345        ("md5sums", "md5"),
346    ] {
347        if let Some(checksums) = variables.get(field) {
348            let checksum_entries: Vec<serde_json::Value> = parse_apkbuild_checksums(checksums)
349                .into_iter()
350                .map(|(file_name, checksum)| serde_json::json!({ "file_name": file_name, checksum_key: checksum }))
351                .collect();
352            if !checksum_entries.is_empty() {
353                match extra_data.get_mut("checksums") {
354                    Some(serde_json::Value::Array(existing)) => existing.extend(checksum_entries),
355                    _ => {
356                        extra_data.insert(
357                            "checksums".to_string(),
358                            serde_json::Value::Array(checksum_entries),
359                        );
360                    }
361                }
362            }
363        }
364    }
365
366    PackageData {
367        datasource_id: Some(DatasourceId::AlpineApkbuild),
368        package_type: Some(PACKAGE_TYPE),
369        namespace: None,
370        name: name.clone(),
371        version: version.clone(),
372        description,
373        homepage_url,
374        extracted_license_statement,
375        declared_license_expression,
376        declared_license_expression_spdx,
377        license_detections,
378        dependencies,
379        purl: name
380            .as_deref()
381            .and_then(|n| build_alpine_purl(n, version.as_deref(), None)),
382        extra_data: (!extra_data.is_empty()).then_some(extra_data),
383        ..default_package_data(DatasourceId::AlpineApkbuild)
384    }
385}
386
387fn parse_apkbuild_variables(content: &str) -> HashMap<String, String> {
388    let mut raw = HashMap::new();
389    let mut lines = content.lines().peekable();
390    let mut brace_depth = 0usize;
391    let mut line_count = 0usize;
392
393    while let Some(line) = lines.next() {
394        line_count += 1;
395        if line_count > MAX_ITERATION_COUNT {
396            warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_variables, truncating");
397            break;
398        }
399        let trimmed = line.trim();
400        if trimmed.is_empty() || trimmed.starts_with('#') {
401            continue;
402        }
403        if trimmed.ends_with("(){") || trimmed.ends_with("() {") {
404            brace_depth += 1;
405            continue;
406        }
407        if brace_depth > 0 {
408            brace_depth += trimmed.chars().filter(|c| *c == '{').count();
409            brace_depth = brace_depth.saturating_sub(trimmed.chars().filter(|c| *c == '}').count());
410            continue;
411        }
412        let Some((name, value)) = trimmed.split_once('=') else {
413            continue;
414        };
415        let mut value = value.trim().to_string();
416        if value.starts_with('"') && !value.ends_with('"') {
417            while let Some(next) = lines.peek() {
418                value.push('\n');
419                value.push_str(next);
420                let current = match lines.next() {
421                    Some(l) => l,
422                    None => break,
423                };
424                if current.trim_end().ends_with('"') {
425                    break;
426                }
427            }
428        }
429        raw.insert(name.trim().to_string(), value);
430    }
431
432    let mut resolved = HashMap::new();
433    for key in [
434        "pkgname",
435        "pkgver",
436        "pkgrel",
437        "pkgdesc",
438        "url",
439        "license",
440        "source",
441        "depends",
442        "depends_dev",
443        "makedepends",
444        "makedepends_build",
445        "makedepends_host",
446        "checkdepends",
447        "sha512sums",
448        "sha256sums",
449        "md5sums",
450    ] {
451        if let Some(value) = raw.get(key) {
452            resolved.insert(key.to_string(), resolve_apkbuild_value(value, &raw));
453        }
454    }
455    resolved
456}
457
458fn resolve_apkbuild_value(value: &str, variables: &HashMap<String, String>) -> String {
459    let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
460    for _ in 0..8 {
461        let previous = resolved.clone();
462        for (name, raw_value) in variables {
463            let raw_value = strip_wrapping_quotes(raw_value.trim());
464            let resolved_raw = resolve_apkbuild_value_no_recursion(raw_value, variables);
465            let value_resolved = strip_wrapping_quotes(&resolved_raw);
466            resolved = resolved.replace(
467                &format!("${{{name}//./-}}"),
468                &value_resolved.replace('.', "-"),
469            );
470            resolved = resolved.replace(
471                &format!("${{{name}//./_}}"),
472                &value_resolved.replace('.', "_"),
473            );
474            resolved = resolved.replace(
475                &format!("${{{name}::8}}"),
476                &value_resolved.chars().take(8).collect::<String>(),
477            );
478            resolved = resolved.replace(&format!("${{{name}}}"), value_resolved);
479            resolved = resolved.replace(&format!("${name}"), value_resolved);
480        }
481        if resolved == previous {
482            break;
483        }
484    }
485    resolved
486}
487
488fn resolve_apkbuild_value_no_recursion(value: &str, variables: &HashMap<String, String>) -> String {
489    let mut resolved = strip_wrapping_quotes(value.trim()).to_string();
490    for (name, raw_value) in variables {
491        let raw_value = strip_wrapping_quotes(raw_value.trim());
492        resolved = resolved.replace(&format!("${{{name}//./-}}"), &raw_value.replace('.', "-"));
493        resolved = resolved.replace(&format!("${{{name}//./_}}"), &raw_value.replace('.', "_"));
494        resolved = resolved.replace(
495            &format!("${{{name}::8}}"),
496            &raw_value.chars().take(8).collect::<String>(),
497        );
498        resolved = resolved.replace(&format!("${{{name}}}"), raw_value);
499        resolved = resolved.replace(&format!("${name}"), raw_value);
500    }
501    resolved
502}
503
504fn strip_wrapping_quotes(value: &str) -> &str {
505    value
506        .strip_prefix('"')
507        .and_then(|v| v.strip_suffix('"'))
508        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
509        .unwrap_or(value)
510}
511
512fn parse_apkbuild_sources(value: &str) -> Vec<(Option<String>, Option<String>)> {
513    value
514        .split_whitespace()
515        .filter(|part| !part.is_empty())
516        .map(|part| {
517            if let Some((file_name, url)) = part.split_once("::") {
518                (Some(file_name.to_string()), Some(url.to_string()))
519            } else if part.contains("://") {
520                (None, Some(part.to_string()))
521            } else {
522                (Some(part.to_string()), None)
523            }
524        })
525        .collect()
526}
527
528fn parse_apkbuild_checksums(value: &str) -> Vec<(String, String)> {
529    value
530        .lines()
531        .flat_map(|line| line.split_whitespace())
532        .collect::<Vec<_>>()
533        .chunks(2)
534        .filter_map(|chunk| {
535            if chunk.len() == 2 {
536                Some((chunk[1].to_string(), chunk[0].to_string()))
537            } else {
538                None
539            }
540        })
541        .collect()
542}
543
544fn build_alpine_license_data(
545    extracted: Option<&str>,
546) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
547    let Some(extracted) = extracted.map(str::trim).filter(|s| !s.is_empty()) else {
548        return empty_declared_license_data();
549    };
550
551    if extracted == "custom:multiple" {
552        return build_declared_license_data_from_pair(
553            "unknown-license-reference",
554            "LicenseRef-provenant-unknown-license-reference",
555            DeclaredLicenseMatchMetadata::single_line(extracted),
556        );
557    }
558
559    let normalized_tokens = extracted
560        .split_whitespace()
561        .filter(|part| *part != "AND")
562        .map(normalize_alpine_license_token)
563        .collect::<Option<Vec<_>>>();
564
565    let Some(normalized_tokens) = normalized_tokens else {
566        return empty_declared_license_data();
567    };
568
569    let Some(combined) = combine_normalized_licenses(normalized_tokens, " AND ") else {
570        return empty_declared_license_data();
571    };
572
573    build_declared_license_data(
574        combined,
575        DeclaredLicenseMatchMetadata::single_line(extracted),
576    )
577}
578
579fn normalize_alpine_license_token(token: &str) -> Option<NormalizedDeclaredLicense> {
580    match token {
581        "ICU" => Some(NormalizedDeclaredLicense::new("x11", "ICU")),
582        "Unicode-TOU" => Some(NormalizedDeclaredLicense::new("unicode-tou", "Unicode-TOU")),
583        "Ruby" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
584        "BSD-2-Clause" => Some(NormalizedDeclaredLicense::new(
585            "bsd-simplified",
586            "BSD-2-Clause",
587        )),
588        "BSD-3-Clause" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
589        other => normalize_declared_license_key(other),
590    }
591}
592
593fn parse_apkbuild_dependencies(variables: &HashMap<String, String>) -> Vec<Dependency> {
594    let mut dependencies = Vec::new();
595    let mut dep_count = 0;
596
597    for (field, scope, is_runtime, is_optional) in [
598        ("depends", "depends", true, false),
599        ("depends_dev", "depends_dev", false, true),
600        ("makedepends", "makedepends", false, true),
601        ("makedepends_build", "makedepends_build", false, true),
602        ("makedepends_host", "makedepends_host", false, true),
603        ("checkdepends", "checkdepends", false, true),
604    ] {
605        let Some(value) = variables.get(field) else {
606            continue;
607        };
608
609        for dep_str in value.split_whitespace() {
610            let dep_str = dep_str.trim();
611            if dep_str.is_empty() {
612                continue;
613            }
614
615            dep_count += 1;
616            if dep_count > MAX_ITERATION_COUNT {
617                warn!("Exceeded MAX_ITERATION_COUNT in parse_apkbuild_dependencies, truncating");
618                return dependencies;
619            }
620
621            let dep_name = dep_str
622                .split(['<', '>', '=', '!', '~'])
623                .next()
624                .unwrap_or(dep_str)
625                .trim();
626            if dep_name.is_empty() {
627                continue;
628            }
629
630            dependencies.push(Dependency {
631                purl: build_alpine_purl(dep_name, None, None),
632                extracted_requirement: Some(dep_str.to_string()),
633                scope: Some(scope.to_string()),
634                is_runtime: Some(is_runtime),
635                is_optional: Some(is_optional),
636                is_pinned: Some(dep_str.contains('=')),
637                is_direct: Some(true),
638                resolved_package: None,
639                extra_data: None,
640            });
641        }
642    }
643
644    dependencies
645}
646
647fn extract_file_references(raw_text: &str) -> Vec<FileReference> {
648    let mut file_references = Vec::new();
649    let mut current_dir = String::new();
650    let mut current_file: Option<FileReference> = None;
651
652    for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
653        if line.is_empty() {
654            continue;
655        }
656
657        if let Some((field_type, value)) = line.split_once(':') {
658            let value = value.trim();
659            match field_type {
660                "F" => {
661                    if let Some(file) = current_file.take() {
662                        file_references.push(file);
663                    }
664                    current_dir = value.to_string();
665                }
666                "R" => {
667                    if let Some(file) = current_file.take() {
668                        file_references.push(file);
669                    }
670
671                    let path = if current_dir.is_empty() {
672                        value.to_string()
673                    } else {
674                        format!("{}/{}", current_dir, value)
675                    };
676
677                    current_file = Some(FileReference {
678                        path,
679                        size: None,
680                        sha1: None,
681                        md5: None,
682                        sha256: None,
683                        sha512: None,
684                        extra_data: None,
685                    });
686                }
687                "Z" => {
688                    if let Some(ref mut file) = current_file
689                        && value.starts_with("Q1")
690                    {
691                        use base64::Engine;
692                        if let Ok(decoded) =
693                            base64::engine::general_purpose::STANDARD.decode(&value[2..])
694                            && let Ok(digest) = Sha1Digest::from_hex(
695                                &decoded
696                                    .iter()
697                                    .map(|b| format!("{:02x}", b))
698                                    .collect::<String>(),
699                            )
700                        {
701                            file.sha1 = Some(digest);
702                        }
703                    }
704                }
705                "a" => {
706                    if let Some(ref mut file) = current_file {
707                        let mut extra = HashMap::new();
708                        extra.insert(
709                            "attributes".to_string(),
710                            serde_json::Value::String(value.to_string()),
711                        );
712                        file.extra_data = Some(extra);
713                    }
714                }
715                _ => {}
716            }
717        }
718    }
719
720    if let Some(file) = current_file {
721        file_references.push(file);
722    }
723
724    file_references
725}
726
727fn extract_providers(raw_text: &str) -> Vec<String> {
728    let mut providers = Vec::new();
729
730    for line in raw_text.lines().take(MAX_ITERATION_COUNT) {
731        if line.is_empty() {
732            continue;
733        }
734
735        if let Some(value) = line.strip_prefix("p:") {
736            providers.extend(value.split_whitespace().map(|s| s.to_string()));
737        }
738    }
739
740    providers
741}
742
743fn build_alpine_purl(
744    name: &str,
745    version: Option<&str>,
746    architecture: Option<&str>,
747) -> Option<String> {
748    use packageurl::PackageUrl;
749
750    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
751
752    if let Some(ver) = version {
753        purl.with_version(ver).ok()?;
754    }
755
756    if let Some(arch) = architecture {
757        purl.add_qualifier("arch", arch).ok()?;
758    }
759
760    Some(purl.to_string())
761}
762
763/// Parser for Alpine Linux .apk package archives
764pub struct AlpineApkParser;
765
766impl PackageParser for AlpineApkParser {
767    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
768
769    fn is_match(path: &Path) -> bool {
770        path.extension().and_then(|e| e.to_str()) == Some("apk")
771            && magic::is_gzip(path)
772            && !magic::is_zip(path)
773            && apk_contains_pkginfo(path)
774    }
775
776    fn extract_packages(path: &Path) -> Vec<PackageData> {
777        vec![match extract_apk_archive(path) {
778            Ok(data) => data,
779            Err(e) => {
780                warn!("Failed to extract .apk archive {:?}: {}", path, e);
781                PackageData {
782                    package_type: Some(PACKAGE_TYPE),
783                    datasource_id: Some(DatasourceId::AlpineApkArchive),
784                    ..Default::default()
785                }
786            }
787        }]
788    }
789}
790
791fn apk_contains_pkginfo(path: &Path) -> bool {
792    use flate2::read::GzDecoder;
793
794    let file = match std::fs::File::open(path) {
795        Ok(file) => file,
796        Err(_) => return false,
797    };
798
799    let archive_size = match std::fs::metadata(path) {
800        Ok(m) => m.len(),
801        Err(_) => return false,
802    };
803
804    if archive_size > MAX_ARCHIVE_SIZE {
805        warn!(
806            "Archive {:?} exceeds MAX_ARCHIVE_SIZE ({} bytes)",
807            path, archive_size
808        );
809        return false;
810    }
811
812    let decoder = GzDecoder::new(file);
813    let mut archive = tar::Archive::new(decoder);
814    let entries = match archive.entries() {
815        Ok(entries) => entries,
816        Err(_) => return false,
817    };
818
819    let mut total_extracted: u64 = 0;
820
821    for entry_result in entries {
822        let entry = match entry_result {
823            Ok(entry) => entry,
824            Err(_) => return false,
825        };
826        let entry_path = match entry.path() {
827            Ok(path) => path,
828            Err(_) => return false,
829        };
830
831        let entry_str = entry_path.to_string_lossy();
832        if entry_str.contains("..") {
833            warn!("Skipping tar entry with path traversal: {}", entry_str);
834            continue;
835        }
836
837        let uncompressed_size = entry.size();
838        if uncompressed_size > MAX_FILE_SIZE {
839            warn!(
840                "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
841                entry_path, path, uncompressed_size
842            );
843            continue;
844        }
845
846        if archive_size > 0 {
847            let ratio = uncompressed_size as f64 / archive_size as f64;
848            if ratio > MAX_COMPRESSION_RATIO {
849                warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
850                continue;
851            }
852        }
853
854        total_extracted += uncompressed_size;
855        if total_extracted > MAX_ARCHIVE_SIZE {
856            warn!("Total extracted size exceeds limit for {:?}", path);
857            return false;
858        }
859
860        if entry_path.ends_with(".PKGINFO") {
861            return true;
862        }
863    }
864
865    false
866}
867
868fn extract_apk_archive(path: &Path) -> Result<PackageData, String> {
869    use flate2::read::GzDecoder;
870    use std::io::Read;
871
872    let file = std::fs::File::open(path).map_err(|e| format!("Failed to open .apk file: {}", e))?;
873
874    let archive_size = std::fs::metadata(path)
875        .map_err(|e| format!("Failed to stat .apk file: {}", e))?
876        .len();
877
878    if archive_size > MAX_ARCHIVE_SIZE {
879        return Err(format!(
880            "Archive {:?} is {} bytes, exceeding MAX_ARCHIVE_SIZE ({} bytes)",
881            path, archive_size, MAX_ARCHIVE_SIZE
882        ));
883    }
884
885    let decoder = GzDecoder::new(file);
886    let mut archive = tar::Archive::new(decoder);
887
888    let mut total_extracted: u64 = 0;
889
890    for entry_result in archive
891        .entries()
892        .map_err(|e| format!("Failed to read tar entries: {}", e))?
893    {
894        let mut entry = entry_result.map_err(|e| format!("Failed to read tar entry: {}", e))?;
895
896        let entry_path = entry
897            .path()
898            .map_err(|e| format!("Failed to get entry path: {}", e))?;
899
900        let entry_str = entry_path.to_string_lossy();
901        if entry_str.contains("..") {
902            warn!("Skipping tar entry with path traversal: {}", entry_str);
903            continue;
904        }
905
906        let uncompressed_size = entry.size();
907        if uncompressed_size > MAX_FILE_SIZE {
908            warn!(
909                "Entry {:?} in {:?} exceeds MAX_FILE_SIZE ({} bytes)",
910                entry_path, path, uncompressed_size
911            );
912            continue;
913        }
914
915        if archive_size > 0 {
916            let ratio = uncompressed_size as f64 / archive_size as f64;
917            if ratio > MAX_COMPRESSION_RATIO {
918                warn!("Suspicious compression ratio in {:?}: {:.2}:1", path, ratio);
919                continue;
920            }
921        }
922
923        total_extracted += uncompressed_size;
924        if total_extracted > MAX_ARCHIVE_SIZE {
925            return Err(format!("Total extracted size exceeds limit for {:?}", path));
926        }
927
928        if entry_path.ends_with(".PKGINFO") {
929            let mut content = String::new();
930            entry
931                .read_to_string(&mut content)
932                .map_err(|e| format!("Failed to read .PKGINFO: {}", e))?;
933
934            return Ok(parse_pkginfo(&content));
935        }
936    }
937
938    Err(".apk archive does not contain .PKGINFO file".to_string())
939}
940
941fn parse_pkginfo(content: &str) -> PackageData {
942    let mut fields: HashMap<&str, Vec<&str>> = HashMap::new();
943
944    for line in content.lines().take(MAX_ITERATION_COUNT) {
945        let line = line.trim();
946        if line.is_empty() || line.starts_with('#') {
947            continue;
948        }
949
950        if let Some((key, value)) = line.split_once(" = ") {
951            fields.entry(key.trim()).or_default().push(value.trim());
952        }
953    }
954
955    let name = fields
956        .get("pkgname")
957        .and_then(|v| v.first())
958        .map(|s| truncate_field(s.to_string()));
959    let pkgver = fields.get("pkgver").and_then(|v| v.first());
960    let version = pkgver.map(|s| truncate_field(s.to_string()));
961    let arch = fields
962        .get("arch")
963        .and_then(|v| v.first())
964        .map(|s| truncate_field(s.to_string()));
965    let license = fields
966        .get("license")
967        .and_then(|v| v.first())
968        .map(|s| truncate_field(s.to_string()));
969    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
970        build_alpine_license_data(license.as_deref());
971    let description = fields
972        .get("pkgdesc")
973        .and_then(|v| v.first())
974        .map(|s| truncate_field(s.to_string()));
975    let homepage = fields
976        .get("url")
977        .and_then(|v| v.first())
978        .map(|s| truncate_field(s.to_string()));
979    let origin = fields
980        .get("origin")
981        .and_then(|v| v.first())
982        .map(|s| truncate_field(s.to_string()));
983    let maintainer_str = fields.get("maintainer").and_then(|v| v.first());
984
985    let mut parties = Vec::new();
986    if let Some(maint) = maintainer_str {
987        let (maint_name, maint_email) = split_name_email(maint);
988        parties.push(Party {
989            r#type: Some("person".to_string()),
990            role: Some("maintainer".to_string()),
991            name: maint_name,
992            email: maint_email,
993            url: None,
994            organization: None,
995            organization_url: None,
996            timezone: None,
997        });
998    }
999
1000    let purl = name
1001        .as_ref()
1002        .and_then(|n| build_alpine_purl(n, version.as_deref(), arch.as_deref()));
1003
1004    let mut dependencies = Vec::new();
1005    if let Some(depends_list) = fields.get("depend") {
1006        for (i, dep_str) in depends_list.iter().enumerate() {
1007            if i >= MAX_ITERATION_COUNT {
1008                warn!("Exceeded MAX_ITERATION_COUNT in parse_pkginfo dependencies, truncating");
1009                break;
1010            }
1011            let dep_name = dep_str.split_whitespace().next().unwrap_or(dep_str);
1012            dependencies.push(Dependency {
1013                purl: Some(format!("pkg:alpine/{}", dep_name)),
1014                extracted_requirement: Some(dep_str.to_string()),
1015                scope: Some("runtime".to_string()),
1016                is_runtime: Some(true),
1017                is_optional: Some(false),
1018                is_pinned: None,
1019                is_direct: Some(true),
1020                resolved_package: None,
1021                extra_data: None,
1022            });
1023        }
1024    }
1025
1026    PackageData {
1027        datasource_id: Some(DatasourceId::AlpineApkArchive),
1028        package_type: Some(PACKAGE_TYPE),
1029        namespace: Some("alpine".to_string()),
1030        name,
1031        version,
1032        description,
1033        homepage_url: homepage,
1034        declared_license_expression,
1035        declared_license_expression_spdx,
1036        license_detections,
1037        extracted_license_statement: license,
1038        parties,
1039        dependencies,
1040        purl,
1041        extra_data: origin.map(|o| {
1042            let mut map = HashMap::new();
1043            map.insert("origin".to_string(), serde_json::Value::String(o));
1044            map
1045        }),
1046        ..Default::default()
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use std::io::Write;
1054    use std::path::PathBuf;
1055    use tempfile::TempDir;
1056
1057    /// Creates a temp file mimicking the Alpine installed db path structure.
1058    /// Returns the TempDir (must be kept alive) and path to the file.
1059    fn create_temp_installed_db(content: &str) -> (TempDir, PathBuf) {
1060        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1061        let db_dir = temp_dir.path().join("lib/apk/db");
1062        std::fs::create_dir_all(&db_dir).expect("Failed to create db dir");
1063        let file_path = db_dir.join("installed");
1064        let mut file = std::fs::File::create(&file_path).expect("Failed to create file");
1065        file.write_all(content.as_bytes())
1066            .expect("Failed to write content");
1067        (temp_dir, file_path)
1068    }
1069
1070    #[test]
1071    fn test_alpine_parser_is_match() {
1072        assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1073            "/lib/apk/db/installed"
1074        )));
1075        assert!(AlpineInstalledParser::is_match(&PathBuf::from(
1076            "/var/lib/apk/db/installed"
1077        )));
1078        assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1079            "/lib/apk/db/status"
1080        )));
1081        assert!(!AlpineInstalledParser::is_match(&PathBuf::from(
1082            "installed"
1083        )));
1084    }
1085
1086    #[test]
1087    fn test_parse_alpine_package_basic() {
1088        let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1089P:alpine-baselayout-data
1090V:3.2.0-r22
1091A:x86_64
1092S:11435
1093I:73728
1094T:Alpine base dir structure and init scripts
1095U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
1096L:GPL-2.0-only
1097o:alpine-baselayout
1098m:Natanael Copa <ncopa@alpinelinux.org>
1099t:1655134784
1100c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1101
1102";
1103        let (_dir, path) = create_temp_installed_db(content);
1104        let pkg = AlpineInstalledParser::extract_first_package(&path);
1105        assert_eq!(pkg.name, Some("alpine-baselayout-data".to_string()));
1106        assert_eq!(pkg.version, Some("3.2.0-r22".to_string()));
1107        assert_eq!(pkg.namespace, Some("alpine".to_string()));
1108        assert_eq!(
1109            pkg.description,
1110            Some("Alpine base dir structure and init scripts".to_string())
1111        );
1112        assert_eq!(
1113            pkg.homepage_url,
1114            Some("https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout".to_string())
1115        );
1116        assert_eq!(
1117            pkg.extracted_license_statement,
1118            Some("GPL-2.0-only".to_string())
1119        );
1120        assert_eq!(pkg.parties.len(), 1);
1121        assert_eq!(pkg.parties[0].name, Some("Natanael Copa".to_string()));
1122        assert_eq!(
1123            pkg.parties[0].email,
1124            Some("ncopa@alpinelinux.org".to_string())
1125        );
1126        assert!(
1127            pkg.purl
1128                .as_ref()
1129                .unwrap()
1130                .contains("alpine-baselayout-data")
1131        );
1132        assert!(pkg.purl.as_ref().unwrap().contains("arch=x86_64"));
1133    }
1134
1135    #[test]
1136    fn test_parse_alpine_with_dependencies() {
1137        let content = "P:musl
1138V:1.2.3-r0
1139A:x86_64
1140D:scanelf so:libc.musl-x86_64.so.1
1141
1142";
1143        let (_dir, path) = create_temp_installed_db(content);
1144        let pkg = AlpineInstalledParser::extract_first_package(&path);
1145        assert_eq!(pkg.name, Some("musl".to_string()));
1146        assert_eq!(pkg.dependencies.len(), 1);
1147        assert!(
1148            pkg.dependencies[0]
1149                .purl
1150                .as_ref()
1151                .unwrap()
1152                .contains("scanelf")
1153        );
1154    }
1155
1156    #[test]
1157    fn test_build_alpine_purl() {
1158        let purl = build_alpine_purl("busybox", Some("1.31.1-r9"), Some("x86_64"));
1159        assert_eq!(
1160            purl,
1161            Some("pkg:alpine/busybox@1.31.1-r9?arch=x86_64".to_string())
1162        );
1163
1164        let purl_no_arch = build_alpine_purl("package", Some("1.0"), None);
1165        assert_eq!(purl_no_arch, Some("pkg:alpine/package@1.0".to_string()));
1166    }
1167
1168    #[test]
1169    fn test_parse_alpine_extra_data() {
1170        let content = "P:test-package
1171V:1.0
1172C:base64checksum==
1173S:12345
1174I:67890
1175t:1234567890
1176c:gitcommithash
1177
1178";
1179        let (_dir, path) = create_temp_installed_db(content);
1180        let pkg = AlpineInstalledParser::extract_first_package(&path);
1181        assert!(pkg.extra_data.is_some());
1182        let extra = pkg.extra_data.as_ref().unwrap();
1183        assert_eq!(extra["checksum"], "base64checksum==");
1184        assert_eq!(extra["compressed_size"], "12345");
1185        assert_eq!(extra["installed_size"], "67890");
1186        assert_eq!(extra["build_timestamp"], "1234567890");
1187        assert_eq!(extra["git_commit"], "gitcommithash");
1188    }
1189
1190    #[test]
1191    fn test_parse_alpine_case_sensitive_keys() {
1192        let content = "C:Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=
1193P:test-pkg
1194V:1.0
1195T:A test description
1196t:1655134784
1197c:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd
1198
1199";
1200        let (_dir, path) = create_temp_installed_db(content);
1201        let pkg = AlpineInstalledParser::extract_first_package(&path);
1202        assert_eq!(pkg.description, Some("A test description".to_string()));
1203        let extra = pkg.extra_data.as_ref().unwrap();
1204        assert_eq!(extra["checksum"], "Q1v4QhLje3kWlC8DJj+ZfJTjlJRSU=");
1205        assert_eq!(extra["build_timestamp"], "1655134784");
1206        assert_eq!(
1207            extra["git_commit"],
1208            "cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1209        );
1210    }
1211
1212    #[test]
1213    fn test_parse_alpine_multiple_packages() {
1214        let content = "P:package1
1215V:1.0
1216A:x86_64
1217
1218P:package2
1219V:2.0
1220A:aarch64
1221
1222";
1223        let (_dir, path) = create_temp_installed_db(content);
1224        let pkgs = AlpineInstalledParser::extract_packages(&path);
1225        assert_eq!(pkgs.len(), 2);
1226        assert_eq!(pkgs[0].name, Some("package1".to_string()));
1227        assert_eq!(pkgs[0].version, Some("1.0".to_string()));
1228        assert_eq!(pkgs[1].name, Some("package2".to_string()));
1229        assert_eq!(pkgs[1].version, Some("2.0".to_string()));
1230    }
1231
1232    #[test]
1233    fn test_parse_alpine_file_references() {
1234        let content = "P:test-pkg
1235V:1.0
1236F:usr/bin
1237R:test
1238Z:Q1WTc55xfvPogzA0YUV24D0Ym+MKE=
1239F:etc
1240R:config
1241Z:Q1pcfTfDNEbNKQc2s1tia7da05M8Q=
1242
1243";
1244        let (_dir, path) = create_temp_installed_db(content);
1245        let pkg = AlpineInstalledParser::extract_first_package(&path);
1246        assert_eq!(pkg.file_references.len(), 2);
1247        assert_eq!(pkg.file_references[0].path, "usr/bin/test");
1248        assert!(pkg.file_references[0].sha1.is_some());
1249        assert_eq!(pkg.file_references[1].path, "etc/config");
1250        assert!(pkg.file_references[1].sha1.is_some());
1251    }
1252
1253    #[test]
1254    fn test_parse_alpine_empty_fields() {
1255        let content = "P:minimal-package
1256V:1.0
1257
1258";
1259        let (_dir, path) = create_temp_installed_db(content);
1260        let pkg = AlpineInstalledParser::extract_first_package(&path);
1261        assert_eq!(pkg.name, Some("minimal-package".to_string()));
1262        assert_eq!(pkg.version, Some("1.0".to_string()));
1263        assert!(pkg.description.is_none());
1264        assert!(pkg.homepage_url.is_none());
1265        assert_eq!(pkg.dependencies.len(), 0);
1266    }
1267
1268    #[test]
1269    fn test_parse_alpine_origin_field() {
1270        let content = "P:busybox-ifupdown
1271V:1.35.0-r13
1272o:busybox
1273A:x86_64
1274
1275";
1276        let (_dir, path) = create_temp_installed_db(content);
1277        let pkg = AlpineInstalledParser::extract_first_package(&path);
1278        assert_eq!(pkg.name, Some("busybox-ifupdown".to_string()));
1279        assert_eq!(pkg.source_packages.len(), 1);
1280        assert_eq!(pkg.source_packages[0], "pkg:alpine/busybox");
1281    }
1282
1283    #[test]
1284    fn test_parse_alpine_url_field() {
1285        let content = "P:openssl
1286V:1.1.1q-r0
1287U:https://www.openssl.org
1288A:x86_64
1289
1290";
1291        let (_dir, path) = create_temp_installed_db(content);
1292        let pkg = AlpineInstalledParser::extract_first_package(&path);
1293        assert_eq!(
1294            pkg.homepage_url,
1295            Some("https://www.openssl.org".to_string())
1296        );
1297    }
1298
1299    #[test]
1300    fn test_parse_alpine_provider_field() {
1301        let content = "P:some-package
1302V:1.0
1303p:cmd:binary=1.0
1304p:so:libtest.so.1
1305
1306";
1307        let (_dir, path) = create_temp_installed_db(content);
1308        let pkg = AlpineInstalledParser::extract_first_package(&path);
1309        assert!(pkg.extra_data.is_some());
1310        let extra = pkg.extra_data.as_ref().unwrap();
1311        let providers = extra.get("providers").and_then(|v| v.as_array());
1312        assert!(providers.is_some());
1313        let provider_array = providers.unwrap();
1314        assert_eq!(provider_array.len(), 2);
1315        assert_eq!(provider_array[0].as_str(), Some("cmd:binary=1.0"));
1316        assert_eq!(provider_array[1].as_str(), Some("so:libtest.so.1"));
1317    }
1318
1319    #[test]
1320    fn test_alpine_apk_parser_is_match() {
1321        let apk_path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1322
1323        assert!(AlpineApkParser::is_match(&apk_path));
1324        assert!(!AlpineApkParser::is_match(&PathBuf::from("package.tar.gz")));
1325        assert!(!AlpineApkParser::is_match(&PathBuf::from("installed")));
1326    }
1327
1328    #[test]
1329    fn test_alpine_apk_parser_rejects_android_and_placeholder_apk_fixtures() {
1330        let android_apk = PathBuf::from("testdata/misc/test_android.apk");
1331        let placeholder_alpine_apk = PathBuf::from("testdata/misc/test_alpine.apk");
1332        let valid_alpine_apk = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1333
1334        assert!(!AlpineApkParser::is_match(&android_apk));
1335        assert!(!AlpineApkParser::is_match(&placeholder_alpine_apk));
1336        assert!(AlpineApkParser::is_match(&valid_alpine_apk));
1337    }
1338
1339    #[test]
1340    fn test_alpine_apkbuild_parser_is_match() {
1341        assert!(AlpineApkbuildParser::is_match(&PathBuf::from("APKBUILD")));
1342        assert!(AlpineApkbuildParser::is_match(&PathBuf::from(
1343            "/path/to/APKBUILD"
1344        )));
1345        assert!(!AlpineApkbuildParser::is_match(&PathBuf::from("apkbuild")));
1346        assert!(!AlpineApkbuildParser::is_match(&PathBuf::from(
1347            "APKBUILD.txt"
1348        )));
1349    }
1350
1351    #[test]
1352    fn test_parse_apkbuild_icu_reference() {
1353        let path = PathBuf::from("testdata/alpine-fixtures/apkbuild/alpine14/main/icu/APKBUILD");
1354        let pkg = AlpineApkbuildParser::extract_first_package(&path);
1355
1356        assert_eq!(pkg.datasource_id, Some(DatasourceId::AlpineApkbuild));
1357        assert_eq!(pkg.name.as_deref(), Some("icu"));
1358        assert_eq!(pkg.version.as_deref(), Some("67.1-r2"));
1359        assert_eq!(
1360            pkg.description.as_deref(),
1361            Some("International Components for Unicode library")
1362        );
1363        assert_eq!(
1364            pkg.homepage_url.as_deref(),
1365            Some("http://site.icu-project.org/")
1366        );
1367        assert_eq!(
1368            pkg.extracted_license_statement.as_deref(),
1369            Some("MIT ICU Unicode-TOU")
1370        );
1371        assert_eq!(
1372            pkg.declared_license_expression_spdx.as_deref(),
1373            Some("ICU AND MIT AND Unicode-TOU")
1374        );
1375        assert_eq!(pkg.dependencies.len(), 3);
1376        let depends_dev = pkg
1377            .dependencies
1378            .iter()
1379            .find(|dep| dep.scope.as_deref() == Some("depends_dev"))
1380            .expect("depends_dev dependency missing");
1381        assert_eq!(depends_dev.purl.as_deref(), Some("pkg:alpine/icu"));
1382        assert_eq!(depends_dev.is_runtime, Some(false));
1383        assert_eq!(depends_dev.is_optional, Some(true));
1384
1385        let check_dep_names: Vec<_> = pkg
1386            .dependencies
1387            .iter()
1388            .filter(|dep| dep.scope.as_deref() == Some("checkdepends"))
1389            .filter_map(|dep| dep.purl.as_deref())
1390            .collect();
1391        assert!(check_dep_names.contains(&"pkg:alpine/diffutils"));
1392        assert!(check_dep_names.contains(&"pkg:alpine/python3"));
1393        let extra = pkg.extra_data.as_ref().unwrap();
1394        assert!(extra.contains_key("sources"));
1395        assert!(extra.contains_key("checksums"));
1396    }
1397
1398    #[test]
1399    fn test_parse_apkbuild_custom_multiple_license_uses_raw_matched_text() {
1400        let path = PathBuf::from(
1401            "testdata/alpine-fixtures/apkbuild/alpine13/main/linux-firmware/APKBUILD",
1402        );
1403        let pkg = AlpineApkbuildParser::extract_first_package(&path);
1404
1405        assert_eq!(pkg.name.as_deref(), Some("linux-firmware"));
1406        assert_eq!(pkg.version.as_deref(), Some("20201218-r0"));
1407        assert_eq!(
1408            pkg.extracted_license_statement.as_deref(),
1409            Some("custom:multiple")
1410        );
1411        assert_eq!(
1412            pkg.declared_license_expression.as_deref(),
1413            Some("unknown-license-reference")
1414        );
1415        assert_eq!(
1416            pkg.declared_license_expression_spdx.as_deref(),
1417            Some("LicenseRef-provenant-unknown-license-reference")
1418        );
1419        let matched = pkg.license_detections[0].matches[0].matched_text.as_deref();
1420        assert_eq!(matched, Some("custom:multiple"));
1421    }
1422
1423    #[test]
1424    fn test_parse_alpine_no_files_package_still_detected() {
1425        let path = PathBuf::from("testdata/alpine-fixtures/full-installed/installed");
1426        let content = std::fs::read_to_string(&path).expect("read installed db fixture");
1427        let packages = parse_alpine_installed_db(&content);
1428        let libc_utils = packages
1429            .into_iter()
1430            .find(|pkg| pkg.name.as_deref() == Some("libc-utils"))
1431            .expect("libc-utils package should exist");
1432
1433        assert_eq!(libc_utils.file_references.len(), 0);
1434        assert!(
1435            libc_utils
1436                .purl
1437                .as_deref()
1438                .is_some_and(|p| p.contains("libc-utils"))
1439        );
1440    }
1441
1442    #[test]
1443    fn test_parse_alpine_commit_generates_https_vcs_url() {
1444        let content =
1445            "P:test-package\nV:1.0-r0\nA:x86_64\nc:cb70ca5c6d6db0399d2dd09189c5d57827bce5cd\n";
1446        let (_dir, path) = create_temp_installed_db(content);
1447        let pkg = AlpineInstalledParser::extract_first_package(&path);
1448
1449        assert_eq!(
1450            pkg.vcs_url.as_deref(),
1451            Some(
1452                "git+https://git.alpinelinux.org/aports/commit/?id=cb70ca5c6d6db0399d2dd09189c5d57827bce5cd"
1453            )
1454        );
1455    }
1456
1457    #[test]
1458    fn test_parse_alpine_virtual_package() {
1459        let content = "P:.postgis-rundeps
1460V:20210104.190748
1461A:noarch
1462S:0
1463I:0
1464T:virtual meta package
1465U:
1466L:
1467D:json-c geos gdal proj protobuf-c libstdc++
1468
1469";
1470        let (_dir, path) = create_temp_installed_db(content);
1471        let pkg = AlpineInstalledParser::extract_first_package(&path);
1472        assert_eq!(pkg.name, Some(".postgis-rundeps".to_string()));
1473        assert_eq!(pkg.version, Some("20210104.190748".to_string()));
1474        assert_eq!(pkg.description, Some("virtual meta package".to_string()));
1475        assert!(pkg.extra_data.is_some());
1476        let extra = pkg.extra_data.as_ref().unwrap();
1477        assert_eq!(
1478            extra.get("is_virtual").and_then(|v| v.as_bool()),
1479            Some(true)
1480        );
1481        assert_eq!(pkg.dependencies.len(), 6);
1482        assert!(pkg.homepage_url.is_none());
1483        assert!(pkg.extracted_license_statement.is_none());
1484    }
1485
1486    #[test]
1487    fn test_installed_db_license_normalization() {
1488        let content = "P:test-package\nV:1.0-r0\nA:x86_64\nL:MIT\n\n";
1489        let (_dir, path) = create_temp_installed_db(content);
1490        let pkg = AlpineInstalledParser::extract_first_package(&path);
1491
1492        assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1493        assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1494        assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1495        assert_eq!(pkg.license_detections.len(), 1);
1496    }
1497
1498    #[test]
1499    fn test_apk_archive_license_normalization() {
1500        let path = PathBuf::from("testdata/alpine/apk/basic/test-package-1.0-r0.apk");
1501        let pkg = AlpineApkParser::extract_first_package(&path);
1502
1503        assert_eq!(pkg.extracted_license_statement.as_deref(), Some("MIT"));
1504        assert_eq!(pkg.declared_license_expression.as_deref(), Some("mit"));
1505        assert_eq!(pkg.declared_license_expression_spdx.as_deref(), Some("MIT"));
1506        assert_eq!(pkg.license_detections.len(), 1);
1507    }
1508}
1509
1510crate::register_parser!(
1511    "Alpine Linux package (installed db and .apk archive)",
1512    &["**/lib/apk/db/installed", "**/*.apk"],
1513    "alpine",
1514    "",
1515    Some("https://wiki.alpinelinux.org/wiki/Apk_spec"),
1516);
1517
1518crate::register_parser!(
1519    "Alpine Linux APKBUILD recipe",
1520    &["**/APKBUILD"],
1521    "alpine",
1522    "Shell",
1523    Some("https://wiki.alpinelinux.org/wiki/APKBUILD_Reference"),
1524);