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