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
80impl AlpineRepo {
81    /// Get the branch and repo parts.
82    fn parts(&self) -> (&'static str, &'static str) {
83        match self {
84            Self::EdgeMain => ("edge", "main"),
85            Self::EdgeCommunity => ("edge", "community"),
86            Self::EdgeTesting => ("edge", "testing"),
87            Self::V321Main => ("v3.21", "main"),
88            Self::V321Community => ("v3.21", "community"),
89            Self::V320Main => ("v3.20", "main"),
90            Self::V320Community => ("v3.20", "community"),
91            Self::V319Main => ("v3.19", "main"),
92            Self::V319Community => ("v3.19", "community"),
93            Self::V318Main => ("v3.18", "main"),
94            Self::V318Community => ("v3.18", "community"),
95        }
96    }
97
98    /// Get the repository name for tagging.
99    pub fn name(&self) -> String {
100        let (branch, repo) = self.parts();
101        format!("{}-{}", branch, repo)
102    }
103
104    /// All available repositories.
105    pub fn all() -> &'static [AlpineRepo] {
106        &[
107            Self::EdgeMain,
108            Self::EdgeCommunity,
109            Self::EdgeTesting,
110            Self::V321Main,
111            Self::V321Community,
112            Self::V320Main,
113            Self::V320Community,
114            Self::V319Main,
115            Self::V319Community,
116            Self::V318Main,
117            Self::V318Community,
118        ]
119    }
120
121    /// Edge repositories only.
122    pub fn edge() -> &'static [AlpineRepo] {
123        &[Self::EdgeMain, Self::EdgeCommunity, Self::EdgeTesting]
124    }
125
126    /// Latest stable version (v3.21).
127    pub fn latest_stable() -> &'static [AlpineRepo] {
128        &[Self::V321Main, Self::V321Community]
129    }
130
131    /// Stable versions only (no edge, no testing).
132    pub fn stable() -> &'static [AlpineRepo] {
133        &[
134            Self::V321Main,
135            Self::V321Community,
136            Self::V320Main,
137            Self::V320Community,
138            Self::V319Main,
139            Self::V319Community,
140            Self::V318Main,
141            Self::V318Community,
142        ]
143    }
144}
145
146/// APK package index fetcher with configurable repositories.
147pub struct Apk {
148    repos: Vec<AlpineRepo>,
149    arch: &'static str,
150}
151
152impl Apk {
153    /// Create a fetcher with all repositories.
154    pub fn all() -> Self {
155        Self {
156            repos: AlpineRepo::all().to_vec(),
157            arch: "x86_64",
158        }
159    }
160
161    /// Create a fetcher with edge repositories only.
162    pub fn edge() -> Self {
163        Self {
164            repos: AlpineRepo::edge().to_vec(),
165            arch: "x86_64",
166        }
167    }
168
169    /// Create a fetcher with the latest stable version.
170    pub fn latest_stable() -> Self {
171        Self {
172            repos: AlpineRepo::latest_stable().to_vec(),
173            arch: "x86_64",
174        }
175    }
176
177    /// Create a fetcher with all stable versions.
178    pub fn stable() -> Self {
179        Self {
180            repos: AlpineRepo::stable().to_vec(),
181            arch: "x86_64",
182        }
183    }
184
185    /// Create a fetcher with custom repository selection.
186    pub fn with_repos(repos: &[AlpineRepo]) -> Self {
187        Self {
188            repos: repos.to_vec(),
189            arch: "x86_64",
190        }
191    }
192
193    /// Set the architecture.
194    pub fn with_arch(mut self, arch: &'static str) -> Self {
195        self.arch = arch;
196        self
197    }
198
199    /// Parse APKINDEX format into PackageMeta entries.
200    fn parse_apkindex<R: Read>(reader: R, repo: AlpineRepo) -> Vec<PackageMeta> {
201        let reader = BufReader::new(reader);
202        let mut packages = Vec::new();
203        let mut current = ApkPackageBuilder::new(repo);
204
205        for line in reader.lines().map_while(Result::ok) {
206            if line.is_empty() {
207                // End of stanza
208                if let Some(pkg) = current.build() {
209                    packages.push(pkg);
210                }
211                current = ApkPackageBuilder::new(repo);
212                continue;
213            }
214
215            // Single-letter field format: "X:value"
216            if line.len() >= 2 && line.chars().nth(1) == Some(':') {
217                let key = line.chars().next().unwrap();
218                let value = &line[2..];
219
220                match key {
221                    'P' => current.name = Some(value.to_string()),
222                    'V' => current.version = Some(value.to_string()),
223                    'T' => current.description = Some(value.to_string()),
224                    'U' => current.homepage = Some(value.to_string()),
225                    'L' => current.license = Some(value.to_string()),
226                    'S' => current.size = value.parse().ok(),
227                    'C' => current.checksum = Some(value.to_string()),
228                    'D' => current.depends = Some(value.to_string()),
229                    'm' => current.maintainer = Some(value.to_string()),
230                    'o' => current.origin = Some(value.to_string()),
231                    'A' => current.arch = Some(value.to_string()),
232                    'p' => current.provides = Some(value.to_string()),
233                    _ => {}
234                }
235            }
236        }
237
238        // Handle last stanza
239        if let Some(pkg) = current.build() {
240            packages.push(pkg);
241        }
242
243        packages
244    }
245
246    /// Fetch and parse APKINDEX.tar.gz from a repository.
247    fn load_repo(&self, repo: AlpineRepo) -> Result<Vec<PackageMeta>, IndexError> {
248        let (branch, repo_name) = repo.parts();
249        let url = format!(
250            "{}/{}/{}/{}/APKINDEX.tar.gz",
251            ALPINE_MIRROR, branch, repo_name, self.arch
252        );
253
254        // Try cache first
255        let (data, _was_cached) = cache::fetch_with_cache(
256            "apk",
257            &format!("apkindex-{}-{}-{}", branch, repo_name, self.arch),
258            &url,
259            INDEX_CACHE_TTL,
260        )
261        .map_err(|e| IndexError::Network(e))?;
262
263        // Check if data is gzip compressed
264        let tar_data = if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b {
265            let mut decoder = MultiGzDecoder::new(Cursor::new(data));
266            let mut decompressed = Vec::new();
267            decoder
268                .read_to_end(&mut decompressed)
269                .map_err(|e| IndexError::Io(e))?;
270            decompressed
271        } else {
272            data
273        };
274
275        let mut archive = Archive::new(Cursor::new(tar_data));
276
277        for entry in archive.entries().map_err(|e| IndexError::Io(e))? {
278            let mut entry = entry.map_err(|e| IndexError::Io(e))?;
279            let path = entry
280                .path()
281                .map_err(|e| IndexError::Io(e))?
282                .to_string_lossy()
283                .to_string();
284
285            // Read entry content - must consume it to advance the iterator
286            let mut content = Vec::new();
287            entry
288                .read_to_end(&mut content)
289                .map_err(|e| IndexError::Io(e))?;
290
291            if path == "APKINDEX" {
292                return Ok(Self::parse_apkindex(Cursor::new(content), repo));
293            }
294        }
295
296        Err(IndexError::Parse("APKINDEX not found in archive".into()))
297    }
298
299    /// Load packages from all configured repositories in parallel.
300    fn load_packages(&self) -> Result<Vec<PackageMeta>, IndexError> {
301        let results: Vec<_> = self
302            .repos
303            .par_iter()
304            .map(|&repo| self.load_repo(repo))
305            .collect();
306
307        let mut packages = Vec::new();
308        for result in results {
309            match result {
310                Ok(pkgs) => packages.extend(pkgs),
311                Err(e) => {
312                    eprintln!("Warning: failed to load Alpine repo: {}", e);
313                }
314            }
315        }
316
317        Ok(packages)
318    }
319}
320
321impl PackageIndex for Apk {
322    fn ecosystem(&self) -> &'static str {
323        "apk"
324    }
325
326    fn display_name(&self) -> &'static str {
327        "APK (Alpine Linux)"
328    }
329
330    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
331        // Search in all configured repos
332        let packages = self.load_packages()?;
333
334        packages
335            .into_iter()
336            .find(|p| p.name == name)
337            .ok_or_else(|| IndexError::NotFound(name.to_string()))
338    }
339
340    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
341        let packages = self.load_packages()?;
342
343        let versions: Vec<_> = packages
344            .into_iter()
345            .filter(|p| p.name == name)
346            .map(|p| VersionMeta {
347                version: p.version,
348                released: None,
349                yanked: false,
350            })
351            .collect();
352
353        if versions.is_empty() {
354            return Err(IndexError::NotFound(name.to_string()));
355        }
356
357        Ok(versions)
358    }
359
360    fn supports_fetch_all(&self) -> bool {
361        true
362    }
363
364    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
365        self.load_packages()
366    }
367
368    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
369        let packages = self.load_packages()?;
370        let query_lower = query.to_lowercase();
371
372        Ok(packages
373            .into_iter()
374            .filter(|p| {
375                p.name.to_lowercase().contains(&query_lower)
376                    || p.description
377                        .as_ref()
378                        .map(|d| d.to_lowercase().contains(&query_lower))
379                        .unwrap_or(false)
380            })
381            .collect())
382    }
383}
384
385/// Builder for APK package metadata.
386#[derive(Default)]
387struct ApkPackageBuilder {
388    repo: Option<AlpineRepo>,
389    name: Option<String>,
390    version: Option<String>,
391    description: Option<String>,
392    homepage: Option<String>,
393    license: Option<String>,
394    size: Option<u64>,
395    checksum: Option<String>,
396    depends: Option<String>,
397    maintainer: Option<String>,
398    origin: Option<String>,
399    arch: Option<String>,
400    provides: Option<String>,
401}
402
403impl ApkPackageBuilder {
404    fn new(repo: AlpineRepo) -> Self {
405        Self {
406            repo: Some(repo),
407            ..Default::default()
408        }
409    }
410
411    fn build(self) -> Option<PackageMeta> {
412        let name = self.name?;
413        let version = self.version?;
414        let repo = self.repo?;
415        let (branch, repo_name) = repo.parts();
416
417        let mut extra = HashMap::new();
418
419        // Parse dependencies
420        if let Some(deps) = self.depends {
421            let parsed_deps: Vec<serde_json::Value> = deps
422                .split_whitespace()
423                .filter(|d| {
424                    // Filter out so: dependencies (shared library deps)
425                    !d.starts_with("so:")
426                })
427                .map(|d| {
428                    // Strip version constraints and prefixes
429                    let name = d
430                        .split(|c| c == '>' || c == '<' || c == '=' || c == '~')
431                        .next()
432                        .unwrap_or(d);
433                    serde_json::Value::String(name.to_string())
434                })
435                .collect();
436            if !parsed_deps.is_empty() {
437                extra.insert("depends".to_string(), serde_json::Value::Array(parsed_deps));
438            }
439        }
440
441        // Store size
442        if let Some(size) = self.size {
443            extra.insert("size".to_string(), serde_json::Value::Number(size.into()));
444        }
445
446        // Store origin package
447        if let Some(origin) = self.origin {
448            extra.insert("origin".to_string(), serde_json::Value::String(origin));
449        }
450
451        // Parse provides (shared libraries and virtual packages)
452        if let Some(provides) = self.provides {
453            let parsed_provides: Vec<serde_json::Value> = provides
454                .split_whitespace()
455                .map(|p| {
456                    // Strip version constraints
457                    let name = p
458                        .split(|c| c == '>' || c == '<' || c == '=' || c == '~')
459                        .next()
460                        .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 c.starts_with("Q1") {
487                format!("sha1-base64:{}", &c[2..])
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}