inapt 0.3.3

A minimal Debian/Ubuntu APT repository proxy written in Rust. Exposes a valid APT repo structure over HTTP, sourcing .deb packages from GitHub Releases.
Documentation
use anyhow::Context;

use crate::domain::entity::ApkMetadata;

/// Extract `.PKGINFO` from an `.apk` file (gzipped tar) and parse it into [`ApkMetadata`].
pub fn from_path(path: &std::path::Path) -> anyhow::Result<ApkMetadata> {
    let file = std::fs::File::open(path).context("unable to open file")?;
    let raw = read_pkginfo(file)?;
    parse_pkginfo(&raw)
}

/// Read the `.PKGINFO` entry from an `.apk` archive.
///
/// APK files are concatenated gzip streams (signature, control, data).
/// `.PKGINFO` lives in the control segment, so we use `MultiGzDecoder`
/// to transparently read across gzip stream boundaries.
fn read_pkginfo<R: std::io::Read>(reader: R) -> anyhow::Result<String> {
    use std::io::Read;

    let gz = flate2::read::MultiGzDecoder::new(reader);
    let mut tar = tar::Archive::new(gz);

    for entry in tar.entries()? {
        let mut entry = entry?;
        let path = entry
            .path()
            .ok()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();
        if path == ".PKGINFO" {
            let mut value = String::new();
            entry.read_to_string(&mut value)?;
            return Ok(value);
        }
    }

    anyhow::bail!(".PKGINFO not found in apk archive")
}

/// Parse `.PKGINFO` key-value content into [`ApkMetadata`].
fn parse_pkginfo(input: &str) -> anyhow::Result<ApkMetadata> {
    let mut builder = PkgInfoBuilder::default();

    for line in input.lines() {
        let line = line.trim();
        // skip empty lines and comments
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((key, value)) = line.split_once(" = ") {
            builder.set(key.trim(), value.trim());
        }
    }

    builder.build()
}

#[derive(Debug, Default)]
struct PkgInfoBuilder {
    name: Option<String>,
    version: Option<String>,
    architecture: Option<String>,
    installed_size: Option<u64>,
    description: Option<String>,
    url: Option<String>,
    license: Option<String>,
    origin: Option<String>,
    maintainer: Option<String>,
    build_date: Option<u64>,
    dependencies: Vec<String>,
    provides: Vec<String>,
    datahash: Option<String>,
}

impl PkgInfoBuilder {
    fn set(&mut self, key: &str, value: &str) {
        match key {
            "pkgname" => self.name = Some(value.to_string()),
            "pkgver" => self.version = Some(value.to_string()),
            "arch" => self.architecture = Some(value.to_string()),
            "size" => self.installed_size = value.parse().ok(),
            "pkgdesc" => self.description = Some(value.to_string()),
            "url" => self.url = Some(value.to_string()),
            "license" => self.license = Some(value.to_string()),
            "origin" => self.origin = Some(value.to_string()),
            "maintainer" => self.maintainer = Some(value.to_string()),
            "builddate" => self.build_date = value.parse().ok(),
            "depend" => self.dependencies.push(value.to_string()),
            "provides" => self.provides.push(value.to_string()),
            "datahash" => self.datahash = Some(value.to_string()),
            // ignore unknown fields (commit, packager, replaces, triggers, etc.)
            _ => {}
        }
    }

    fn build(self) -> anyhow::Result<ApkMetadata> {
        Ok(ApkMetadata {
            name: self
                .name
                .ok_or_else(|| anyhow::anyhow!("missing pkgname in .PKGINFO"))?,
            version: self
                .version
                .ok_or_else(|| anyhow::anyhow!("missing pkgver in .PKGINFO"))?,
            architecture: self
                .architecture
                .ok_or_else(|| anyhow::anyhow!("missing arch in .PKGINFO"))?,
            installed_size: self
                .installed_size
                .ok_or_else(|| anyhow::anyhow!("missing size in .PKGINFO"))?,
            description: self
                .description
                .ok_or_else(|| anyhow::anyhow!("missing pkgdesc in .PKGINFO"))?,
            url: self
                .url
                .ok_or_else(|| anyhow::anyhow!("missing url in .PKGINFO"))?,
            license: self
                .license
                .ok_or_else(|| anyhow::anyhow!("missing license in .PKGINFO"))?,
            origin: self.origin,
            maintainer: self.maintainer,
            build_date: self.build_date,
            dependencies: self.dependencies,
            provides: self.provides,
            datahash: self.datahash,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn should_parse_pkginfo_with_all_fields() {
        let input = r#"# Generated by abuild 3.14.1-r4
# using fakeroot version 1.37.1.1
pkgname = busybox
pkgver = 1.37.0-r14
pkgdesc = Size optimized toolbox of many common UNIX utilities
url = https://busybox.net/
builddate = 1763903404
packager = Buildozer <alpine-devel@lists.alpinelinux.org>
size = 817257
arch = x86_64
origin = busybox
commit = 284d827a2793963e6dcf88324bd1eb81adff579c
maintainer = Sören Tempel <soeren+alpine@soeren-tempel.net>
license = GPL-2.0-only
replaces = busybox-initscripts
triggers = /bin /usr/bin /sbin /usr/sbin /lib/modules/* /usr/lib/modules/*
# automatically detected:
provides = cmd:busybox=1.37.0-r14
depend = so:libc.musl-x86_64.so.1
datahash = dba362efdaf5615e7193972f24e56c73ac395830a1756afe35246f42e8f1de56
"#;
        let meta = parse_pkginfo(input).unwrap();
        assert_eq!(meta.name, "busybox");
        assert_eq!(meta.version, "1.37.0-r14");
        assert_eq!(meta.architecture, "x86_64");
        assert_eq!(meta.installed_size, 817257);
        assert_eq!(
            meta.description,
            "Size optimized toolbox of many common UNIX utilities"
        );
        assert_eq!(meta.url, "https://busybox.net/");
        assert_eq!(meta.license, "GPL-2.0-only");
        assert_eq!(meta.origin.as_deref(), Some("busybox"));
        assert_eq!(
            meta.maintainer.as_deref(),
            Some("Sören Tempel <soeren+alpine@soeren-tempel.net>")
        );
        assert_eq!(meta.build_date, Some(1763903404));
        assert_eq!(meta.dependencies, vec!["so:libc.musl-x86_64.so.1"]);
        assert_eq!(meta.provides, vec!["cmd:busybox=1.37.0-r14"]);
        assert_eq!(
            meta.datahash.as_deref(),
            Some("dba362efdaf5615e7193972f24e56c73ac395830a1756afe35246f42e8f1de56")
        );
    }

    #[test]
    fn should_parse_pkginfo_with_minimal_fields() {
        let input = r#"# Generated by abuild 3.14.0-r0
pkgname = alpine-keys
pkgver = 2.5-r0
pkgdesc = Public keys for Alpine Linux packages
url = https://alpinelinux.org
builddate = 1723638620
size = 14212
arch = x86_64
origin = alpine-keys
maintainer = Natanael Copa <ncopa@alpinelinux.org>
license = MIT
datahash = c72f1654a3503e252d02681b55c7074c6620310333523d8830ec3edb199e5327
"#;
        let meta = parse_pkginfo(input).unwrap();
        assert_eq!(meta.name, "alpine-keys");
        assert_eq!(meta.version, "2.5-r0");
        assert!(meta.dependencies.is_empty());
        assert!(meta.provides.is_empty());
    }

    #[test]
    fn should_fail_when_pkgname_missing() {
        let input = "pkgver = 1.0\narch = x86_64\nsize = 100\npkgdesc = test\nurl = http://test\nlicense = MIT\n";
        let err = parse_pkginfo(input).unwrap_err();
        assert!(err.to_string().contains("pkgname"));
    }

    #[test]
    fn should_extract_pkginfo_from_busybox_apk_file() {
        let local = std::env::current_dir().unwrap();
        let path = local.join("resources").join("busybox-1.37.0-r14.apk");
        let meta = from_path(&path).unwrap();
        assert_eq!(meta.name, "busybox");
        assert_eq!(meta.version, "1.37.0-r14");
        assert_eq!(meta.architecture, "x86_64");
        assert_eq!(meta.installed_size, 817257);
        assert_eq!(meta.license, "GPL-2.0-only");
        assert!(!meta.dependencies.is_empty());
        assert!(!meta.provides.is_empty());
        assert!(meta.datahash.is_some());
    }

    #[test]
    fn should_extract_pkginfo_from_alpine_keys_apk_file() {
        let local = std::env::current_dir().unwrap();
        let path = local.join("resources").join("alpine-keys-2.5-r0.apk");
        let meta = from_path(&path).unwrap();
        assert_eq!(meta.name, "alpine-keys");
        assert_eq!(meta.version, "2.5-r0");
        assert_eq!(meta.architecture, "x86_64");
        assert_eq!(meta.installed_size, 14212);
        assert_eq!(meta.license, "MIT");
        assert!(meta.dependencies.is_empty());
        assert!(meta.provides.is_empty());
    }
}