portable-network-archive 0.37.0

Portable-Network-Archive cli
Documentation
use pna::ReadOptions;
use pna::prelude::*;
use std::{
    fs::File,
    io::{self, Read, Write},
    path::Path,
};

/// Definition for creating a file entry with specific permissions
pub struct FileEntryDef<'a> {
    pub path: &'a str,
    pub content: &'a [u8],
    pub permission: u16,
}

/// Constructs an [`pna::ExtendedAttribute`] from raw name/value, panicking on
/// length-bound violations. Test-only helper; production code must propagate
/// the [`pna::LengthExceeded`] error instead.
pub fn xattr(name: &str, value: &[u8]) -> pna::ExtendedAttribute {
    pna::ExtendedAttribute::new(
        pna::XattrName::try_from(name).expect("xattr name fits within u32::MAX bytes"),
        pna::XattrValue::try_from(value).expect("xattr value fits within u32::MAX bytes"),
    )
}

/// Creates an archive with file entries having specific permissions.
/// This bypasses filesystem permission requirements by constructing entries programmatically.
pub fn create_archive_with_permissions(
    archive_path: impl AsRef<Path>,
    entries: &[FileEntryDef],
) -> io::Result<()> {
    let file = File::create(archive_path)?;
    let mut archive = pna::Archive::write_header(file)?;

    for entry_def in entries {
        let mut builder = pna::FileEntryBuilder::new(entry_def.path.into())?;
        builder.metadata(
            pna::Metadata::new()
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(entry_def.permission))),
        );
        builder.write_all(entry_def.content)?;
        let entry = builder.build()?;
        archive.add_entry(entry)?;
    }

    archive.finalize()?;
    Ok(())
}

/// Creates a solid archive with file entries having specific permissions.
pub fn create_solid_archive_with_permissions(
    archive_path: impl AsRef<Path>,
    entries: &[FileEntryDef],
) -> io::Result<()> {
    let file = File::create(archive_path)?;
    let mut archive = pna::Archive::write_header(file)?;

    let mut solid_builder = pna::SolidEntryBuilder::new(pna::WriteOptions::store())?;
    for entry_def in entries {
        let mut builder = pna::FileEntryBuilder::new(entry_def.path.into())?;
        builder.metadata(
            pna::Metadata::new()
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(entry_def.permission))),
        );
        builder.write_all(entry_def.content)?;
        let entry = builder.build()?;
        solid_builder.add_entry(entry)?;
    }
    let solid_entry = solid_builder.build()?;
    archive.add_entry(solid_entry)?;

    archive.finalize()?;
    Ok(())
}

/// Creates an encrypted solid archive with file entries having specific permissions.
pub fn create_encrypted_solid_archive_with_permissions(
    archive_path: impl AsRef<Path>,
    entries: &[FileEntryDef],
    password: &str,
) -> io::Result<()> {
    let file = File::create(archive_path)?;
    let mut archive = pna::Archive::write_header(file)?;

    let write_options = pna::WriteOptions::builder()
        .password(Some(password))
        .encryption(pna::Encryption::AES)
        .cipher_mode(pna::CipherMode::GCM)
        .build();

    let mut solid_builder = pna::SolidEntryBuilder::new(write_options)?;
    for entry_def in entries {
        let mut builder = pna::FileEntryBuilder::new_with_options(
            entry_def.path.into(),
            pna::WriteOptions::store(),
        )?;
        builder.metadata(
            pna::Metadata::new()
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(entry_def.permission))),
        );
        builder.write_all(entry_def.content)?;
        let entry = builder.build()?;
        solid_builder.add_entry(entry)?;
    }
    let solid_entry = solid_builder.build()?;
    archive.add_entry(solid_entry)?;

    archive.finalize()?;
    Ok(())
}

/// Creates an encrypted archive with file entries having specific permissions.
pub fn create_encrypted_archive_with_permissions(
    archive_path: impl AsRef<Path>,
    entries: &[FileEntryDef],
    password: &str,
) -> io::Result<()> {
    let file = File::create(archive_path)?;
    let mut archive = pna::Archive::write_header(file)?;

    let write_options = pna::WriteOptions::builder()
        .password(Some(password))
        .encryption(pna::Encryption::AES)
        .cipher_mode(pna::CipherMode::CTR)
        .build();

    for entry_def in entries {
        let mut builder =
            pna::FileEntryBuilder::new_with_options(entry_def.path.into(), write_options.clone())?;
        builder.metadata(
            pna::Metadata::new()
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(entry_def.permission))),
        );
        builder.write_all(entry_def.content)?;
        let entry = builder.build()?;
        archive.add_entry(entry)?;
    }

    archive.finalize()?;
    Ok(())
}

pub fn extract_single_entry(
    path: impl AsRef<Path>,
    name: &str,
) -> io::Result<Option<pna::NormalEntry>> {
    let mut archive = pna::Archive::open(path)?;
    let entries = archive
        .entries()
        .extract_solid_entries(&ReadOptions::builder().build());
    for entry in entries {
        let entry = entry?;
        if entry.header().path() == name {
            return Ok(Some(entry));
        }
    }
    Ok(None)
}

pub fn for_each_entry<F>(path: impl AsRef<Path>, f: F) -> io::Result<()>
where
    F: FnMut(pna::NormalEntry),
{
    for_each_entry_with_password(path, None, f)
}

pub fn for_each_entry_with_password<'a, F>(
    path: impl AsRef<Path>,
    password: impl Into<Option<&'a str>>,
    mut f: F,
) -> io::Result<()>
where
    F: FnMut(pna::NormalEntry),
{
    let password = password.into().map(|p| p.as_bytes());
    let mut archive = pna::Archive::open(path)?;
    let read_options = ReadOptions::with_password(password);
    let entries = archive.entries().extract_solid_entries(&read_options);
    for entry in entries {
        f(entry?);
    }
    Ok(())
}

pub fn entry_mode(path: impl AsRef<Path>, name: &str) -> u16 {
    entry_mode_with_password(path, name, None)
}

pub fn entry_mode_with_password(path: impl AsRef<Path>, name: &str, password: Option<&str>) -> u16 {
    let mut mode = None;
    for_each_entry_with_password(path, password, |entry| {
        if entry.name() == name {
            mode = Some(
                entry
                    .metadata()
                    .permission_mode()
                    .expect("entry should have permission mode metadata")
                    .get()
                    & 0o777,
            );
        }
    })
    .unwrap();
    mode.expect("target entry should exist")
}

pub fn entry_contents_with_password(
    path: impl AsRef<Path>,
    name: &str,
    password: Option<&str>,
) -> Vec<u8> {
    let password_bytes = password.map(str::as_bytes);
    let mut contents = None;
    for_each_entry_with_password(path, password, |entry| {
        if entry.name() == name {
            let mut reader = entry
                .reader(pna::ReadOptions::with_password(password_bytes))
                .unwrap();
            let mut data = Vec::new();
            reader.read_to_end(&mut data).unwrap();
            contents = Some(data);
        }
    })
    .unwrap();
    contents.expect("target entry should exist")
}

pub fn read_symlink_target(entry: &pna::NormalEntry) -> String {
    let mut target = Vec::new();
    entry
        .reader(pna::ReadOptions::with_password::<&[u8]>(None))
        .unwrap()
        .read_to_end(&mut target)
        .unwrap();
    String::from_utf8(target).unwrap()
}

/// Creates a simple archive with named text entries.
pub fn create_test_archive(path: impl AsRef<Path>, entries: &[(&str, &str)]) {
    let path = path.as_ref();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).unwrap();
    }
    let file = File::create(path).unwrap();
    let mut writer = pna::Archive::write_header(file).unwrap();
    for (name, contents) in entries {
        writer
            .add_entry({
                let mut builder = pna::FileEntryBuilder::new((*name).into()).unwrap();
                builder.write_all(contents.as_bytes()).unwrap();
                builder.build().unwrap()
            })
            .unwrap();
    }
    writer.finalize().unwrap();
}

/// Definition for creating a symlink entry with optional metadata
pub struct SymlinkEntryDef<'a> {
    pub path: &'a str,
    pub target: &'a str,
    pub permission: Option<u16>,
    pub modified: Option<pna::Duration>,
    pub accessed: Option<pna::Duration>,
    pub created: Option<pna::Duration>,
    pub link_target_type: Option<pna::LinkTargetType>,
}

/// Creates an archive containing both file and symlink entries with specific metadata.
/// This bypasses filesystem requirements by constructing entries programmatically.
pub fn create_archive_with_symlinks(
    archive_path: impl AsRef<Path>,
    file_entries: &[FileEntryDef],
    symlink_entries: &[SymlinkEntryDef],
) -> io::Result<()> {
    let file = File::create(archive_path)?;
    let mut archive = pna::Archive::write_header(file)?;

    for entry_def in file_entries {
        let mut builder = pna::FileEntryBuilder::new(entry_def.path.into())?;
        builder.metadata(
            pna::Metadata::new()
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(entry_def.permission))),
        );
        builder.write_all(entry_def.content)?;
        let entry = builder.build()?;
        archive.add_entry(entry)?;
    }

    for symlink_def in symlink_entries {
        let mut builder =
            pna::SymlinkEntryBuilder::new(symlink_def.path.into(), symlink_def.target.into())?;
        let mut metadata = pna::Metadata::new().with_link_target_type(symlink_def.link_target_type);
        if let Some(mode) = symlink_def.permission {
            metadata = metadata
                .with_owner_uid(Some(pna::OwnerUid::from(1000)))
                .with_owner_gid(Some(pna::OwnerGid::from(1000)))
                .with_owner_user_name(Some(pna::OwnerUserName::new("user").unwrap()))
                .with_owner_group_name(Some(pna::OwnerGroupName::new("group").unwrap()))
                .with_permission_mode(Some(pna::PermissionMode::from(mode)));
        }
        if let Some(m) = symlink_def.modified {
            metadata = metadata.with_modified(Some(m));
        }
        if let Some(a) = symlink_def.accessed {
            metadata = metadata.with_accessed(Some(a));
        }
        if let Some(c) = symlink_def.created {
            metadata = metadata.with_created(Some(c));
        }
        builder.metadata(metadata);
        let entry = builder.build()?;
        archive.add_entry(entry)?;
    }

    archive.finalize()?;
    Ok(())
}

/// Collects all entry names from an archive.
pub fn get_archive_entry_names(path: impl AsRef<Path>) -> Vec<String> {
    let mut names = Vec::new();
    for_each_entry(path, |entry| {
        names.push(entry.header().path().to_string());
    })
    .unwrap();
    names
}

/// Flips one byte in the data field of the first chunk of `target` type.
/// With `recompute_crc: false` the stored CRC no longer matches (CRC-level
/// corruption); with `true` the CRC is updated so the corruption is only
/// detectable by decoding the data stream.
/// Returns whether a matching non-empty chunk was found and corrupted.
pub fn corrupt_first_chunk(
    path: impl AsRef<Path>,
    target: [u8; 4],
    recompute_crc: bool,
) -> io::Result<bool> {
    let mut bytes = std::fs::read(&path)?;
    let mut pos = 8; // skip PNA signature
    while pos + 12 <= bytes.len() {
        let len = u32::from_be_bytes(bytes[pos..pos + 4].try_into().unwrap()) as usize;
        let ty: [u8; 4] = bytes[pos + 4..pos + 8].try_into().unwrap();
        if ty == target && len > 0 {
            let data_start = pos + 8;
            bytes[data_start] ^= 0xFF;
            if recompute_crc {
                // SAFETY: `ty` was read from a valid archive, so it is a valid chunk type.
                let chunk_type = unsafe { pna::ChunkType::from_unchecked(ty) };
                let chunk = pna::RawChunk::from_data(
                    chunk_type,
                    bytes[data_start..data_start + len].to_vec(),
                );
                let crc_pos = data_start + len;
                bytes[crc_pos..crc_pos + 4].copy_from_slice(&chunk.crc().to_be_bytes());
            }
            std::fs::write(&path, bytes)?;
            return Ok(true);
        }
        pos += 12 + len; // always advances at least 12 bytes per iteration
    }
    Ok(false)
}