Skip to main content

normalize_package_index/index/
apk.rs

1//! APK package index fetcher (Alpine Linux).
2//!
3//! Fetches package metadata from Alpine Linux repositories by parsing
4//! APKINDEX.tar.gz files from mirrors.
5//!
6//! ## API Strategy
7//! - **fetch**: Parses APKINDEX.tar.gz from `dl-cdn.alpinelinux.org` (official mirror)
8//! - **fetch_versions**: Loads from all configured repos
9//! - **search**: Filters cached APKINDEX entries
10//! - **fetch_all**: Returns all packages from APKINDEX (cached 1 hour)
11//!
12//! ## Multi-repo Support
13//! ```rust,ignore
14//! use normalize_packages::index::apk::{Apk, AlpineRepo};
15//!
16//! // All repos (default)
17//! let all = Apk::all();
18//!
19//! // Edge only
20//! let edge = Apk::edge();
21//!
22//! // Specific version
23//! let v321 = Apk::version("v3.21");
24//!
25//! // Custom selection
26//! let custom = Apk::with_repos(&[AlpineRepo::EdgeMain, AlpineRepo::EdgeCommunity]);
27//! ```
28
29use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
30use crate::cache;
31use flate2::read::MultiGzDecoder;
32use rayon::prelude::*;
33use std::collections::HashMap;
34use std::io::{BufRead, BufReader, Cursor, Read};
35use std::time::Duration;
36use tar::Archive;
37
38/// Cache TTL for APKINDEX (1 hour).
39const INDEX_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
40
41/// Alpine mirror URL.
42const ALPINE_MIRROR: &str = "https://dl-cdn.alpinelinux.org/alpine";
43
44/// Available Alpine Linux repositories.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum AlpineRepo {
47    // === Edge (rolling release) ===
48    /// Edge main repository
49    EdgeMain,
50    /// Edge community repository
51    EdgeCommunity,
52    /// Edge testing repository (unstable)
53    EdgeTesting,
54
55    // === v3.21 ===
56    /// Alpine 3.21 main repository
57    V321Main,
58    /// Alpine 3.21 community repository
59    V321Community,
60
61    // === v3.20 ===
62    /// Alpine 3.20 main repository
63    V320Main,
64    /// Alpine 3.20 community repository
65    V320Community,
66
67    // === v3.19 ===
68    /// Alpine 3.19 main repository
69    V319Main,
70    /// Alpine 3.19 community repository
71    V319Community,
72
73    // === v3.18 ===
74    /// Alpine 3.18 main repository
75    V318Main,
76    /// Alpine 3.18 community repository
77    V318Community,
78}
79
80struct RepoParts {
81    branch: &'static str,
82    repo: &'static str,
83}
84
85impl AlpineRepo {
86    /// Get the branch and repo parts.
87    fn parts(&self) -> RepoParts {
88        let (branch, repo) = match self {
89            Self::EdgeMain => ("edge", "main"),
90            Self::EdgeCommunity => ("edge", "community"),
91            Self::EdgeTesting => ("edge", "testing"),
92            Self::V321Main => ("v3.21", "main"),
93            Self::V321Community => ("v3.21", "community"),
94            Self::V320Main => ("v3.20", "main"),
95            Self::V320Community => ("v3.20", "community"),
96            Self::V319Main => ("v3.19", "main"),
97            Self::V319Community => ("v3.19", "community"),
98            Self::V318Main => ("v3.18", "main"),
99            Self::V318Community => ("v3.18", "community"),
100        };
101        RepoParts { branch, repo }
102    }
103
104    /// Get the repository name for tagging.
105    pub fn name(&self) -> String {
106        let parts = self.parts();
107        format!("{}-{}", parts.branch, parts.repo)
108    }
109
110    /// All available repositories.
111    pub fn all() -> &'static [AlpineRepo] {
112        &[
113            Self::EdgeMain,
114            Self::EdgeCommunity,
115            Self::EdgeTesting,
116            Self::V321Main,
117            Self::V321Community,
118            Self::V320Main,
119            Self::V320Community,
120            Self::V319Main,
121            Self::V319Community,
122            Self::V318Main,
123            Self::V318Community,
124        ]
125    }
126
127    /// Edge repositories only.
128    pub fn edge() -> &'static [AlpineRepo] {
129        &[Self::EdgeMain, Self::EdgeCommunity, Self::EdgeTesting]
130    }
131
132    /// Latest stable version (v3.21).
133    pub fn latest_stable() -> &'static [AlpineRepo] {
134        &[Self::V321Main, Self::V321Community]
135    }
136
137    /// Stable versions only (no edge, no testing).
138    pub fn stable() -> &'static [AlpineRepo] {
139        &[
140            Self::V321Main,
141            Self::V321Community,
142            Self::V320Main,
143            Self::V320Community,
144            Self::V319Main,
145            Self::V319Community,
146            Self::V318Main,
147            Self::V318Community,
148        ]
149    }
150}
151
152/// APK package index fetcher with configurable repositories.
153pub struct Apk {
154    repos: Vec<AlpineRepo>,
155    arch: &'static str,
156}
157
158impl Apk {
159    /// Create a fetcher with all repositories.
160    pub fn all() -> Self {
161        Self {
162            repos: AlpineRepo::all().to_vec(),
163            arch: "x86_64",
164        }
165    }
166
167    /// Create a fetcher with edge repositories only.
168    pub fn edge() -> Self {
169        Self {
170            repos: AlpineRepo::edge().to_vec(),
171            arch: "x86_64",
172        }
173    }
174
175    /// Create a fetcher with the latest stable version.
176    pub fn latest_stable() -> Self {
177        Self {
178            repos: AlpineRepo::latest_stable().to_vec(),
179            arch: "x86_64",
180        }
181    }
182
183    /// Create a fetcher with all stable versions.
184    pub fn stable() -> Self {
185        Self {
186            repos: AlpineRepo::stable().to_vec(),
187            arch: "x86_64",
188        }
189    }
190
191    /// Create a fetcher with custom repository selection.
192    pub fn with_repos(repos: &[AlpineRepo]) -> Self {
193        Self {
194            repos: repos.to_vec(),
195            arch: "x86_64",
196        }
197    }
198
199    /// Set the architecture.
200    pub fn with_arch(mut self, arch: &'static str) -> Self {
201        self.arch = arch;
202        self
203    }
204
205    /// Parse APKINDEX format into PackageMeta entries.
206    fn parse_apkindex<R: Read>(reader: R, repo: AlpineRepo) -> Vec<PackageMeta> {
207        let reader = BufReader::new(reader);
208        let mut packages = Vec::new();
209        let mut current = ApkPackageBuilder::new(repo);
210
211        for line in reader.lines().map_while(Result::ok) {
212            if line.is_empty() {
213                // End of stanza
214                if let Some(pkg) = current.build() {
215                    packages.push(pkg);
216                }
217                current = ApkPackageBuilder::new(repo);
218                continue;
219            }
220
221            // Single-letter field format: "X:value"
222            if line.len() >= 2 && line.chars().nth(1) == Some(':') {
223                // normalize-syntax-allow: rust/unwrap-in-impl - guarded by len() >= 2 check
224                let key = line.chars().next().unwrap();
225                let value = &line[2..];
226
227                match key {
228                    'P' => current.name = Some(value.to_string()),
229                    'V' => current.version = Some(value.to_string()),
230                    'T' => current.description = Some(value.to_string()),
231                    'U' => current.homepage = Some(value.to_string()),
232                    'L' => current.license = Some(value.to_string()),
233                    'S' => current.size = value.parse().ok(),
234                    'C' => current.checksum = Some(value.to_string()),
235                    'D' => current.depends = Some(value.to_string()),
236                    'm' => current.maintainer = Some(value.to_string()),
237                    'o' => current.origin = Some(value.to_string()),
238                    'A' => current.arch = Some(value.to_string()),
239                    'p' => current.provides = Some(value.to_string()),
240                    _ => {}
241                }
242            }
243        }
244
245        // Handle last stanza
246        if let Some(pkg) = current.build() {
247            packages.push(pkg);
248        }
249
250        packages
251    }
252
253    /// Fetch and parse APKINDEX.tar.gz from a repository.
254    fn load_repo(&self, repo: AlpineRepo) -> Result<Vec<PackageMeta>, IndexError> {
255        let parts = repo.parts();
256        let url = format!(
257            "{}/{}/{}/{}/APKINDEX.tar.gz",
258            ALPINE_MIRROR, parts.branch, parts.repo, self.arch
259        );
260
261        // Try cache first
262        let (data, _was_cached) = cache::fetch_with_cache(
263            "apk",
264            &format!("apkindex-{}-{}-{}", parts.branch, parts.repo, self.arch),
265            &url,
266            INDEX_CACHE_TTL,
267        )
268        .map_err(IndexError::Network)?;
269
270        // Check if data is gzip compressed
271        let tar_data = if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b {
272            let mut decoder = MultiGzDecoder::new(Cursor::new(data));
273            let mut decompressed = Vec::new();
274            decoder
275                .read_to_end(&mut decompressed)
276                .map_err(IndexError::Io)?;
277            decompressed
278        } else {
279            data
280        };
281
282        let mut archive = Archive::new(Cursor::new(tar_data));
283
284        for entry in archive.entries().map_err(IndexError::Io)? {
285            let mut entry = entry.map_err(IndexError::Io)?;
286            let path = entry
287                .path()
288                .map_err(IndexError::Io)?
289                .to_string_lossy()
290                .to_string();
291
292            // Read entry content - must consume it to advance the iterator
293            let mut content = Vec::new();
294            entry.read_to_end(&mut content).map_err(IndexError::Io)?;
295
296            if path == "APKINDEX" {
297                return Ok(Self::parse_apkindex(Cursor::new(content), repo));
298            }
299        }
300
301        Err(IndexError::Parse("APKINDEX not found in archive".into()))
302    }
303
304    /// Load packages from all configured repositories in parallel.
305    fn load_packages(&self) -> Result<Vec<PackageMeta>, IndexError> {
306        let results: Vec<_> = self
307            .repos
308            .par_iter()
309            .map(|&repo| self.load_repo(repo))
310            .collect();
311
312        let mut packages = Vec::new();
313        for result in results {
314            match result {
315                Ok(pkgs) => packages.extend(pkgs),
316                Err(e) => {
317                    tracing::warn!("failed to load Alpine repo: {}", e);
318                }
319            }
320        }
321
322        Ok(packages)
323    }
324}
325
326impl PackageIndex for Apk {
327    fn ecosystem(&self) -> &'static str {
328        "apk"
329    }
330
331    fn display_name(&self) -> &'static str {
332        "APK (Alpine Linux)"
333    }
334
335    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
336        // Search in all configured repos
337        let packages = self.load_packages()?;
338
339        packages
340            .into_iter()
341            .find(|p| p.name == name)
342            .ok_or_else(|| IndexError::NotFound(name.to_string()))
343    }
344
345    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
346        let packages = self.load_packages()?;
347
348        let versions: Vec<_> = packages
349            .into_iter()
350            .filter(|p| p.name == name)
351            .map(|p| VersionMeta {
352                version: p.version,
353                released: None,
354                yanked: false,
355            })
356            .collect();
357
358        if versions.is_empty() {
359            return Err(IndexError::NotFound(name.to_string()));
360        }
361
362        Ok(versions)
363    }
364
365    fn supports_fetch_all(&self) -> bool {
366        true
367    }
368
369    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
370        self.load_packages()
371    }
372
373    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
374        let packages = self.load_packages()?;
375        let query_lower = query.to_lowercase();
376
377        Ok(packages
378            .into_iter()
379            .filter(|p| {
380                p.name.to_lowercase().contains(&query_lower)
381                    || p.description
382                        .as_ref()
383                        .map(|d| d.to_lowercase().contains(&query_lower))
384                        .unwrap_or(false)
385            })
386            .collect())
387    }
388}
389
390/// Builder for APK package metadata.
391#[derive(Default)]
392struct ApkPackageBuilder {
393    repo: Option<AlpineRepo>,
394    name: Option<String>,
395    version: Option<String>,
396    description: Option<String>,
397    homepage: Option<String>,
398    license: Option<String>,
399    size: Option<u64>,
400    checksum: Option<String>,
401    depends: Option<String>,
402    maintainer: Option<String>,
403    origin: Option<String>,
404    arch: Option<String>,
405    provides: Option<String>,
406}
407
408impl ApkPackageBuilder {
409    fn new(repo: AlpineRepo) -> Self {
410        Self {
411            repo: Some(repo),
412            ..Default::default()
413        }
414    }
415
416    fn build(self) -> Option<PackageMeta> {
417        let name = self.name?;
418        let version = self.version?;
419        let repo = self.repo?;
420        let repo_parts = repo.parts();
421        let (branch, repo_name) = (repo_parts.branch, repo_parts.repo);
422
423        let mut extra = HashMap::new();
424
425        // Parse dependencies
426        if let Some(deps) = self.depends {
427            let parsed_deps: Vec<serde_json::Value> = deps
428                .split_whitespace()
429                .filter(|d| {
430                    // Filter out so: dependencies (shared library deps)
431                    !d.starts_with("so:")
432                })
433                .map(|d| {
434                    // Strip version constraints and prefixes
435                    let name = d.split(['>', '<', '=', '~']).next().unwrap_or(d);
436                    serde_json::Value::String(name.to_string())
437                })
438                .collect();
439            if !parsed_deps.is_empty() {
440                extra.insert("depends".to_string(), serde_json::Value::Array(parsed_deps));
441            }
442        }
443
444        // Store size
445        if let Some(size) = self.size {
446            extra.insert("size".to_string(), serde_json::Value::Number(size.into()));
447        }
448
449        // Store origin package
450        if let Some(origin) = self.origin {
451            extra.insert("origin".to_string(), serde_json::Value::String(origin));
452        }
453
454        // Parse provides (shared libraries and virtual packages)
455        if let Some(provides) = self.provides {
456            let parsed_provides: Vec<serde_json::Value> = provides
457                .split_whitespace()
458                .map(|p| {
459                    // Strip version constraints
460                    let name = p.split(['>', '<', '=', '~']).next().unwrap_or(p);
461                    serde_json::Value::String(name.to_string())
462                })
463                .collect();
464            if !parsed_provides.is_empty() {
465                extra.insert(
466                    "provides".to_string(),
467                    serde_json::Value::Array(parsed_provides),
468                );
469            }
470        }
471
472        // Tag with source repo
473        extra.insert(
474            "source_repo".to_string(),
475            serde_json::Value::String(repo.name()),
476        );
477
478        // Build download URL
479        let archive_url = Some(format!(
480            "{}/{}/{}/x86_64/{}-{}.apk",
481            ALPINE_MIRROR, branch, repo_name, name, version
482        ));
483
484        // Convert checksum (Q1... is SHA1 in base64)
485        let checksum = self.checksum.map(|c| {
486            if let Some(stripped) = c.strip_prefix("Q1") {
487                format!("sha1-base64:{}", stripped)
488            } else {
489                c
490            }
491        });
492
493        Some(PackageMeta {
494            name,
495            version,
496            description: self.description,
497            homepage: self.homepage,
498            repository: None,
499            license: self.license,
500            binaries: Vec::new(),
501            keywords: Vec::new(),
502            maintainers: self.maintainer.into_iter().collect(),
503            published: None,
504            downloads: None,
505            archive_url,
506            checksum,
507            extra,
508        })
509    }
510}