repro-env 0.1.0

Dependency lockfiles for a reproducible build environment 📦🔒
Documentation
use crate::args;
use crate::container::{self, Container};
use crate::errors::*;
use crate::lockfile::{ContainerLock, PackageLock};
use crate::manifest::PackagesManifest;
use flate2::read::GzDecoder;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use tokio::signal;

#[derive(Debug, Default, PartialEq)]
pub struct Package {
    pub values: HashMap<String, Vec<String>>,
}

impl Package {
    pub fn parse(buf: &str) -> Result<Self> {
        let mut pkg = Self::default();

        let mut lines = buf.lines();
        while let Some(section) = lines.next() {
            let mut values = Vec::new();
            for line in &mut lines {
                if line.is_empty() {
                    break;
                }
                values.push(line.to_string());
            }
            pkg.values.insert(section.to_string(), values);
        }

        Ok(pkg)
    }

    pub fn add_values(&mut self, key: &str, values: &[&str]) {
        let values = values.iter().map(|s| s.to_string()).collect();
        self.values.insert(key.to_string(), values);
    }

    pub fn single_value(&self, key: &str) -> Result<&str> {
        let values = self
            .values
            .get(key)
            .with_context(|| anyhow!("Failed to find key in package metadata: {key:?}"))?;
        let mut values = values.iter();

        let value = values
            .next()
            .with_context(|| anyhow!("No value available for {key:?}"))?;

        if let Some(trailing) = values.next() {
            bail!("Unexpected trailing value in {key:?}: {trailing:?}");
        }

        Ok(value)
    }

    pub fn name(&self) -> Result<&str> {
        self.single_value("%NAME%")
    }

    pub fn archive_url(&self) -> Result<String> {
        let filename = self.single_value("%FILENAME%")?;
        let pkgname = self.name()?;
        let idx = pkgname
            .chars()
            .next()
            .context("Name for package is empty")?;
        Ok(format!(
            "https://archive.archlinux.org/packages/{idx}/{pkgname}/{filename}"
        ))
    }

    pub fn sha256(&self) -> Result<&str> {
        self.single_value("%SHA256SUM%")
    }

    pub fn signature(&self) -> Result<&str> {
        self.single_value("%PGPSIG%")
    }
}

#[derive(Debug, Default)]
pub struct DatabaseCache {
    imported_repositories: HashSet<String>,
    packages: HashMap<String, Package>,
}

impl DatabaseCache {
    pub fn has_repo(&self, repo: &str) -> bool {
        self.imported_repositories.contains(repo)
    }

    pub fn import_repo(&mut self, repo: &str, buf: &[u8]) -> Result<()> {
        let d = GzDecoder::new(buf);
        let mut tar = tar::Archive::new(d);

        for entry in tar.entries()? {
            let mut entry = entry?;
            if entry.header().entry_type() == tar::EntryType::Regular {
                let mut buf = String::new();
                trace!("Reading package from archive: {:?}", entry.path());
                entry
                    .read_to_string(&mut buf)
                    .context("Failed to read database entry")?;

                let pkg =
                    Package::parse(&buf).context("Failed to parse database entry as package")?;

                self.packages.insert(pkg.name()?.to_string(), pkg);
            }
        }

        self.imported_repositories.insert(repo.to_string());
        Ok(())
    }

    pub fn get_package(&self, name: &str) -> Result<&Package> {
        self.packages
            .get(name)
            .context("Failed to find package in any database: {name:?}")
    }
}

pub async fn resolve_dependencies(
    container: &Container,
    manifest: &PackagesManifest,
    dependencies: &mut Vec<PackageLock>,
    keep: bool,
) -> Result<()> {
    info!("Syncing package datatabase...");
    container
        .exec(&["pacman", "-Sy"], container::Exec::default())
        .await?;

    info!("Resolving dependencies...");
    let mut cmd = vec!["pacman", "-Sup", "--print-format", "%r %n %v", "--"];
    for dep in &manifest.dependencies {
        cmd.push(dep.as_str());
    }
    let buf = container
        .exec(
            &cmd,
            container::Exec {
                capture_stdout: true,
                ..Default::default()
            },
        )
        .await?;
    let buf = String::from_utf8(buf).context("Failed to decode pacman output as utf8")?;

    let mut dbs = DatabaseCache::default();
    for line in buf.lines() {
        let mut line = line.split(' ');
        let repo = line.next().context("Missing repo in pacman output")?;
        let name = line.next().context("Missing pkg name in pacman output")?;
        let version = line.next().context("Missing version in pacman output")?;
        if let Some(trailing) = line.next() {
            bail!("Trailing data in pacman output: {trailing:?}");
        }

        debug!("Detected dependency name={name:?} version={version:?} repo={repo:?}");
        if !dbs.has_repo(repo) {
            let buf = container
                .cat(&format!("/var/lib/pacman/sync/{repo}.db"))
                .await?;
            dbs.import_repo(repo, &buf)?;
        }

        let pkg = dbs.get_package(name)?;

        dependencies.push(PackageLock {
            name: name.to_string(),
            version: version.to_string(),
            system: "archlinux".to_string(),
            url: pkg.archive_url()?,
            sha256: pkg.sha256()?.to_string(),
            signature: Some(pkg.signature()?.to_string()),
        });
    }

    if keep {
        info!("Keeping container around until ^C...");
        futures::future::pending().await
    } else {
        Ok(())
    }
}

pub async fn resolve(
    update: &args::Update,
    manifest: &PackagesManifest,
    container: &ContainerLock,
    dependencies: &mut Vec<PackageLock>,
) -> Result<()> {
    debug!("Creating container...");
    let init = &["/__".to_string(), "-P".to_string()];
    let container = Container::create(
        &container.image,
        container::Config {
            init,
            mounts: &[],
            expose_fuse: false,
        },
    )
    .await?;
    let container_id = container.id.clone();
    let result = tokio::select! {
        result = resolve_dependencies(&container, manifest, dependencies, update.keep) => result,
        _ = signal::ctrl_c() => Err(anyhow!("Ctrl-c received")),
    };
    debug!("Removing container...");
    if let Err(err) = container.kill().await {
        warn!("Failed to kill container {:?}: {:#}", container_id, err);
    }
    debug!("Container cleanup complete");
    result
}

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

    #[test]
    fn parse_pkg_entry() -> Result<()> {
        let buf = r#"%FILENAME%
zstd-1.5.5-1-x86_64.pkg.tar.zst

%NAME%
zstd

%BASE%
zstd

%VERSION%
1.5.5-1

%DESC%
Zstandard - Fast real-time compression algorithm

%CSIZE%
493009

%ISIZE%
1500453

%MD5SUM%
2ba620ed7816b97bcad1a721a2a9f6c4

%SHA256SUM%
1891970afabc725e72c6a9bb2c127d906c1d3cc70309336fbe87adbd460c05b8

%PGPSIG%
iQEzBAABCgAdFiEE5JnHn1PJalTlcv7hwGCGM3xQdz4FAmQ79ZMACgkQwGCGM3xQdz4V+Qf/Yz7Y+3WwSDKtspwcaEr3j95n1nN5+SAThl/OHe94WwmInDWV09GwM+Lrw6Y1RFDK1PI1ZLON3hOo/81udW0uCHJ4n0bnU/2x3B4UW82dcBqFBjiEqNEF1x6KcQGf9PE9seZndsiAxVzrbEH9u48RIHx0SuwWnzlryCoHPYTgYsPrpkH0IzLUerP2Lc8rjUR2eAKn6zoomb3mR74dPNMn2yx9gS0l+79EshQR8kWtOVvTv7xgRriWeJMBNoTTvDfiDq5B8395vPaBmSfrU0O3tvVF3eDAGtpxIb8hqfhtRqy3XqTcRrYaoj44KtJraGCbq5DrsImEdx5byS7qBhoheQ==

%URL%
https://facebook.github.io/zstd/

%LICENSE%
BSD
GPL2

%ARCH%
x86_64

%BUILDDATE%
1681646714

%PACKAGER%
Jelle van der Waa <jelle@archlinux.org>

%PROVIDES%
libzstd.so=1-64

%DEPENDS%
glibc
gcc-libs
zlib
xz
lz4

%MAKEDEPENDS%
cmake
gtest
ninja
"#;
        let pkg = Package::parse(buf)?;

        let mut expected = Package::default();
        expected.add_values("%FILENAME%", &["zstd-1.5.5-1-x86_64.pkg.tar.zst"]);
        expected.add_values("%NAME%", &["zstd"]);
        expected.add_values("%BASE%", &["zstd"]);
        expected.add_values("%VERSION%", &["1.5.5-1"]);
        expected.add_values(
            "%DESC%",
            &["Zstandard - Fast real-time compression algorithm"],
        );

        expected.add_values("%CSIZE%", &["493009"]);
        expected.add_values("%ISIZE%", &["1500453"]);
        expected.add_values("%MD5SUM%", &["2ba620ed7816b97bcad1a721a2a9f6c4"]);
        expected.add_values(
            "%SHA256SUM%",
            &["1891970afabc725e72c6a9bb2c127d906c1d3cc70309336fbe87adbd460c05b8"],
        );
        expected.add_values("%PGPSIG%", &[
"iQEzBAABCgAdFiEE5JnHn1PJalTlcv7hwGCGM3xQdz4FAmQ79ZMACgkQwGCGM3xQdz4V+Qf/Yz7Y+3WwSDKtspwcaEr3j95n1nN5+SAThl/OHe94WwmInDWV09GwM+Lrw6Y1RFDK1PI1ZLON3hOo/81udW0uCHJ4n0bnU/2x3B4UW82dcBqFBjiEqNEF1x6KcQGf9PE9seZndsiAxVzrbEH9u48RIHx0SuwWnzlryCoHPYTgYsPrpkH0IzLUerP2Lc8rjUR2eAKn6zoomb3mR74dPNMn2yx9gS0l+79EshQR8kWtOVvTv7xgRriWeJMBNoTTvDfiDq5B8395vPaBmSfrU0O3tvVF3eDAGtpxIb8hqfhtRqy3XqTcRrYaoj44KtJraGCbq5DrsImEdx5byS7qBhoheQ=="]);
        expected.add_values("%URL%", &["https://facebook.github.io/zstd/"]);
        expected.add_values("%LICENSE%", &["BSD", "GPL2"]);
        expected.add_values("%ARCH%", &["x86_64"]);
        expected.add_values("%BUILDDATE%", &["1681646714"]);
        expected.add_values("%PACKAGER%", &["Jelle van der Waa <jelle@archlinux.org>"]);
        expected.add_values("%PROVIDES%", &["libzstd.so=1-64"]);
        expected.add_values("%DEPENDS%", &["glibc", "gcc-libs", "zlib", "xz", "lz4"]);
        expected.add_values("%MAKEDEPENDS%", &["cmake", "gtest", "ninja"]);

        assert_eq!(pkg, expected);
        Ok(())
    }
}