Skip to main content

provenant/parsers/
alpine.rs

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