debrepo 0.4.0

Library for manifest-driven Debian/Ubuntu bootstrap and APT archive resolution.
Documentation
use {
    async_compression::futures::write::{
        BzEncoder, GzipEncoder, Lz4Encoder, XzEncoder, ZstdEncoder,
    },
    debrepo::{
        comp::{comp_reader, is_comp_ext, is_tar_ext, strip_comp_ext, tar_reader},
        tar::TarEntry,
        CompressionLevel,
    },
    smol::io::{AsyncReadExt, AsyncWriteExt, Cursor},
    smol::stream::StreamExt,
    std::io,
};

fn write_tar_str(field: &mut [u8], value: &str) {
    let bytes = value.as_bytes();
    assert!(
        bytes.len() <= field.len(),
        "tar field too small for {value}"
    );
    field[..bytes.len()].copy_from_slice(bytes);
}

fn write_tar_octal(field: &mut [u8], value: u64) {
    let width = field.len();
    let text = format!("{value:0width$o}\0", width = width.saturating_sub(1));
    let bytes = text.as_bytes();
    let start = width.saturating_sub(bytes.len());
    field[start..start + bytes.len()].copy_from_slice(bytes);
}

fn build_tar(path: &str, data: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    let mut header = [0u8; 512];

    write_tar_str(&mut header[0..100], path);
    write_tar_octal(&mut header[100..108], 0o644);
    write_tar_octal(&mut header[108..116], 0);
    write_tar_octal(&mut header[116..124], 0);
    write_tar_octal(&mut header[124..136], data.len() as u64);
    write_tar_octal(&mut header[136..148], 0);
    header[148..156].fill(b' ');
    header[156] = b'0';
    write_tar_str(&mut header[257..263], "ustar\0");
    write_tar_str(&mut header[263..265], "00");

    let checksum: u32 = header.iter().map(|b| u32::from(*b)).sum();
    let checksum_text = format!("{checksum:06o}\0 ");
    header[148..156].copy_from_slice(checksum_text.as_bytes());

    out.extend_from_slice(&header);
    out.extend_from_slice(data);
    let padding = (512 - (data.len() % 512)) % 512;
    out.resize(out.len() + padding, 0);
    out.resize(out.len() + 1024, 0);
    out
}

async fn compress_bytes(kind: &str, data: &[u8]) -> io::Result<Vec<u8>> {
    match kind {
        "plain" => Ok(data.to_vec()),
        "gz" => {
            let mut writer =
                GzipEncoder::with_quality(Cursor::new(Vec::new()), CompressionLevel::Default);
            writer.write_all(data).await?;
            writer.close().await?;
            Ok(writer.into_inner().into_inner())
        }
        "xz" => {
            let mut writer =
                XzEncoder::with_quality(Cursor::new(Vec::new()), CompressionLevel::Default);
            writer.write_all(data).await?;
            writer.close().await?;
            Ok(writer.into_inner().into_inner())
        }
        "bz2" => {
            let mut writer =
                BzEncoder::with_quality(Cursor::new(Vec::new()), CompressionLevel::Default);
            writer.write_all(data).await?;
            writer.close().await?;
            Ok(writer.into_inner().into_inner())
        }
        "lz4" => {
            let mut writer =
                Lz4Encoder::with_quality(Cursor::new(Vec::new()), CompressionLevel::Default);
            writer.write_all(data).await?;
            writer.close().await?;
            Ok(writer.into_inner().into_inner())
        }
        "zst" | "zstd" => {
            let mut writer =
                ZstdEncoder::with_quality(Cursor::new(Vec::new()), CompressionLevel::Default);
            writer.write_all(data).await?;
            writer.close().await?;
            Ok(writer.into_inner().into_inner())
        }
        other => panic!("unsupported test compression kind {other}"),
    }
}

async fn read_file_entry(
    mut reader: debrepo::tar::TarReader<
        '_,
        std::pin::Pin<Box<dyn smol::io::AsyncRead + Send + '_>>,
    >,
) -> io::Result<(String, Vec<u8>)> {
    let entry = reader
        .next()
        .await
        .transpose()?
        .expect("expected tar entry");
    let mut body = Vec::new();
    match entry {
        TarEntry::File(mut file) => {
            file.read_to_end(&mut body).await?;
            let path = file.path().to_string();
            assert!(reader.next().await.is_none(), "expected single tar entry");
            Ok((path, body))
        }
        other => panic!("unexpected tar entry: {other:?}"),
    }
}

#[test]
fn strip_comp_ext_and_is_comp_ext_cover_supported_suffixes() {
    assert_eq!(strip_comp_ext("Packages.gz"), "Packages");
    assert_eq!(strip_comp_ext("Sources.XZ"), "Sources");
    assert_eq!(strip_comp_ext("Packages.BZ2"), "Packages");
    assert_eq!(strip_comp_ext("Packages.ZST"), "Packages");
    assert_eq!(strip_comp_ext("Packages.zstd"), "Packages");
    assert_eq!(strip_comp_ext("Packages.LZ4"), "Packages");
    assert_eq!(strip_comp_ext("plain"), "plain");
    assert_eq!(strip_comp_ext("a"), "a");

    assert!(is_comp_ext("Packages.gz"));
    assert!(is_comp_ext("Packages.XZ"));
    assert!(is_comp_ext("Packages.bz2"));
    assert!(is_comp_ext("Packages.zst"));
    assert!(is_comp_ext("Packages.ZSTD"));
    assert!(is_comp_ext("Packages.lz4"));
    assert!(!is_comp_ext("gz"));
    assert!(!is_comp_ext("archive.tar"));
}

#[test]
fn comp_reader_roundtrips_supported_algorithms_and_plain_passthrough() {
    smol::block_on(async {
        let payload = b"component payload\n";
        for (uri, kind) in [
            ("Packages.bz2", "bz2"),
            ("Packages.lz4", "lz4"),
            ("Packages.zst", "zst"),
            ("Packages.zstd", "zstd"),
            ("Packages", "plain"),
        ] {
            let encoded = compress_bytes(kind, payload)
                .await
                .expect("compress payload");
            let mut reader = comp_reader(uri, Cursor::new(encoded));
            let mut decoded = Vec::new();
            reader
                .read_to_end(&mut decoded)
                .await
                .expect("decode payload");
            assert_eq!(decoded, payload, "uri {uri}");
        }
    });
}

#[test]
fn is_tar_ext_covers_aliases_and_rejections() {
    for path in [
        "archive.tar",
        "archive.tar.gz",
        "archive.TGZ",
        "archive.tar.xz",
        "archive.txz",
        "archive.tar.bz2",
        "archive.tbz",
        "archive.tbz2",
        "archive.tar.zstd",
        "archive.tar.zst",
        "archive.tzst",
    ] {
        assert!(is_tar_ext(path), "expected tar extension for {path}");
    }

    assert!(!is_tar_ext("archive.tar.lz4"));
    assert!(!is_tar_ext("archive.zip"));
}

#[test]
fn tar_reader_reads_supported_compressed_tars_and_rejects_unknown_format() {
    smol::block_on(async {
        let tar_bytes = build_tar("entry.txt", b"tar payload");
        for (uri, kind) in [
            ("archive.tar.gz", "gz"),
            ("archive.txz", "xz"),
            ("archive.tbz2", "bz2"),
            ("archive.tzst", "zstd"),
        ] {
            let encoded = compress_bytes(kind, &tar_bytes)
                .await
                .expect("compress tar");
            let reader = tar_reader(uri, Cursor::new(encoded)).expect("open tar reader");
            let (path, body) = read_file_entry(reader).await.expect("read tar entry");
            assert_eq!(path, "entry.txt", "uri {uri}");
            assert_eq!(body, b"tar payload", "uri {uri}");
        }

        let err = match tar_reader("archive.zip", Cursor::new(tar_bytes)) {
            Ok(_) => panic!("unsupported archive should fail"),
            Err(err) => err,
        };
        assert!(err
            .to_string()
            .contains("unsupported archive format archive.zip"));
    });
}