use std::collections::HashMap;
use std::ffi::OsString;
use std::fmt;
use std::fmt::Write as FmtWrite;
use std::fs::{self, File, Permissions};
use std::io::{self, BufReader, Cursor, Read, Seek, SeekFrom, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use tar::{Archive as TarArchive, Builder as TarBuilder, Entries, Header};
use crate::metadata::{Entry, FileRead, Metadata};
use crate::plist::Plist;
use crate::summary::Summary;
fn parse_mode(mode_str: &str) -> Option<u32> {
u32::from_str_radix(mode_str, 8).ok()
}
pub const DEFAULT_BLOCK_SIZE: usize = 65536;
pub const PKGSRC_SIGNATURE_VERSION: u32 = 1;
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
pub type Result<T> = std::result::Result<T, ArchiveError>;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Compression {
None,
#[default]
Gzip,
Zstd,
}
impl Compression {
#[must_use]
pub fn from_magic(bytes: &[u8]) -> Option<Self> {
if bytes.len() < ZSTD_MAGIC.len() {
return None;
}
if bytes.starts_with(&GZIP_MAGIC) {
Some(Self::Gzip)
} else if bytes.starts_with(&ZSTD_MAGIC) {
Some(Self::Zstd)
} else {
None
}
}
#[must_use]
pub fn from_extension(path: impl AsRef<Path>) -> Option<Self> {
let name = path.as_ref().file_name()?.to_str()?;
let lower = name.to_lowercase();
if lower.ends_with(".tgz") || lower.ends_with(".tar.gz") {
Some(Self::Gzip)
} else if lower.ends_with(".tzst") || lower.ends_with(".tar.zst") {
Some(Self::Zstd)
} else if lower.ends_with(".tar") {
Some(Self::None)
} else {
None
}
}
#[must_use]
pub fn extension(&self) -> &'static str {
match self {
Self::None => "tar",
Self::Gzip => "tgz",
Self::Zstd => "tzst",
}
}
}
impl fmt::Display for Compression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Gzip => write!(f, "gzip"),
Self::Zstd => write!(f, "zstd"),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PkgHashAlgorithm {
#[default]
Sha512,
Sha256,
}
impl PkgHashAlgorithm {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Sha512 => "SHA512",
Self::Sha256 => "SHA256",
}
}
#[must_use]
pub fn hash_size(&self) -> usize {
match self {
Self::Sha512 => 64,
Self::Sha256 => 32,
}
}
#[must_use]
pub fn hash(&self, data: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256, Sha512};
match self {
Self::Sha512 => Sha512::digest(data).to_vec(),
Self::Sha256 => Sha256::digest(data).to_vec(),
}
}
#[must_use]
pub fn hash_hex(&self, data: &[u8]) -> String {
let bytes = self.hash(data);
let mut s = String::with_capacity(bytes.len() * 2);
for b in &bytes {
let _ = write!(s, "{b:02x}");
}
s
}
}
impl fmt::Display for PkgHashAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for PkgHashAlgorithm {
type Err = ArchiveError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"SHA512" => Ok(Self::Sha512),
"SHA256" => Ok(Self::Sha256),
_ => Err(ArchiveError::UnsupportedAlgorithm(s.to_string())),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ArchiveError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("invalid archive format: {0}")]
InvalidFormat(String),
#[error("invalid +PKG_HASH format: {0}")]
InvalidPkgHash(String),
#[error("missing required metadata: {0}")]
MissingMetadata(String),
#[error("invalid metadata: {0}")]
InvalidMetadata(String),
#[error("plist error: {0}")]
Plist(#[from] crate::plist::PlistError),
#[error("hash verification failed: {0}")]
HashMismatch(String),
#[error("unsupported hash algorithm: {0}")]
UnsupportedAlgorithm(String),
#[error("unsupported compression: {0}")]
UnsupportedCompression(String),
#[error("summary error: {0}")]
Summary(String),
#[error("no path available: {0}")]
NoPath(String),
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct ExtractOptions {
pub apply_mode: bool,
pub apply_ownership: bool,
pub preserve_mtime: bool,
}
impl ExtractOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_mode(mut self) -> Self {
self.apply_mode = true;
self
}
#[must_use]
pub fn with_ownership(mut self) -> Self {
self.apply_ownership = true;
self
}
#[must_use]
pub fn with_mtime(mut self) -> Self {
self.preserve_mtime = true;
self
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ExtractedFile {
pub path: PathBuf,
pub is_metadata: bool,
pub expected_checksum: Option<String>,
pub mode: Option<u32>,
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PkgHash {
version: u32,
pkgname: String,
algorithm: PkgHashAlgorithm,
block_size: usize,
file_size: u64,
hashes: Vec<String>,
}
impl PkgHash {
#[must_use]
pub fn new(pkgname: impl Into<String>) -> Self {
Self {
version: PKGSRC_SIGNATURE_VERSION,
pkgname: pkgname.into(),
algorithm: PkgHashAlgorithm::default(),
block_size: DEFAULT_BLOCK_SIZE,
file_size: 0,
hashes: Vec::new(),
}
}
pub fn from_tarball<R: Read>(
pkgname: impl Into<String>,
mut reader: R,
algorithm: PkgHashAlgorithm,
block_size: usize,
) -> Result<Self> {
let mut pkg_hash = PkgHash::new(pkgname);
pkg_hash.algorithm = algorithm;
pkg_hash.block_size = block_size;
let mut buffer = vec![0u8; block_size];
let mut total_size: u64 = 0;
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
total_size += bytes_read as u64;
let hash = algorithm.hash_hex(&buffer[..bytes_read]);
pkg_hash.hashes.push(hash);
}
pkg_hash.file_size = total_size;
Ok(pkg_hash)
}
#[must_use]
pub fn version(&self) -> u32 {
self.version
}
#[must_use]
pub fn pkgname(&self) -> &str {
&self.pkgname
}
#[must_use]
pub fn algorithm(&self) -> PkgHashAlgorithm {
self.algorithm
}
#[must_use]
pub fn block_size(&self) -> usize {
self.block_size
}
#[must_use]
pub fn file_size(&self) -> u64 {
self.file_size
}
#[must_use]
pub fn hashes(&self) -> &[String] {
&self.hashes
}
pub fn verify<R: Read>(&self, mut reader: R) -> Result<bool> {
let mut buffer = vec![0u8; self.block_size];
let mut hash_idx = 0;
let mut total_size: u64 = 0;
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
total_size += bytes_read as u64;
if hash_idx >= self.hashes.len() {
return Err(ArchiveError::HashMismatch(
"more data than expected".into(),
));
}
let computed = self.algorithm.hash_hex(&buffer[..bytes_read]);
if computed != self.hashes[hash_idx] {
return Err(ArchiveError::HashMismatch(format!(
"block {} hash mismatch",
hash_idx
)));
}
hash_idx += 1;
}
if total_size != self.file_size {
return Err(ArchiveError::HashMismatch(format!(
"file size mismatch: expected {}, got {}",
self.file_size, total_size
)));
}
if hash_idx != self.hashes.len() {
return Err(ArchiveError::HashMismatch(
"fewer blocks than expected".into(),
));
}
Ok(true)
}
}
impl fmt::Display for PkgHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "pkgsrc signature")?;
writeln!(f, "version: {}", self.version)?;
writeln!(f, "pkgname: {}", self.pkgname)?;
writeln!(f, "algorithm: {}", self.algorithm)?;
writeln!(f, "block size: {}", self.block_size)?;
writeln!(f, "file size: {}", self.file_size)?;
for hash in &self.hashes {
writeln!(f, "{}", hash)?;
}
Ok(())
}
}
impl std::str::FromStr for PkgHash {
type Err = ArchiveError;
fn from_str(s: &str) -> Result<Self> {
let lines: Vec<&str> = s.lines().collect();
if lines.is_empty() || lines[0] != "pkgsrc signature" {
return Err(ArchiveError::InvalidPkgHash(
"missing 'pkgsrc signature' header".into(),
));
}
let mut pkg_hash = PkgHash::default();
let mut header_complete = false;
let mut line_idx = 1;
while line_idx < lines.len() && !header_complete {
let line = lines[line_idx];
if let Some((key, value)) = line.split_once(": ") {
match key {
"version" => {
pkg_hash.version = value.parse().map_err(|_| {
ArchiveError::InvalidPkgHash(format!(
"invalid version: {}",
value
))
})?;
}
"pkgname" => {
pkg_hash.pkgname = value.to_string();
}
"algorithm" => {
pkg_hash.algorithm = value.parse()?;
}
"block size" => {
pkg_hash.block_size = value.parse().map_err(|_| {
ArchiveError::InvalidPkgHash(format!(
"invalid block size: {}",
value
))
})?;
}
"file size" => {
pkg_hash.file_size = value.parse().map_err(|_| {
ArchiveError::InvalidPkgHash(format!(
"invalid file size: {}",
value
))
})?;
header_complete = true;
}
_ => {
return Err(ArchiveError::InvalidPkgHash(format!(
"unknown header field: {}",
key
)));
}
}
} else if !line.is_empty() {
header_complete = true;
line_idx -= 1;
}
line_idx += 1;
}
while line_idx < lines.len() {
let line = lines[line_idx].trim();
if !line.is_empty() {
pkg_hash.hashes.push(line.to_string());
}
line_idx += 1;
}
if pkg_hash.pkgname.is_empty() {
return Err(ArchiveError::InvalidPkgHash("missing pkgname".into()));
}
Ok(pkg_hash)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ArchiveType {
Unsigned,
Signed,
}
#[doc(hidden)]
#[allow(clippy::large_enum_variant)]
pub enum Decoder<R: Read> {
None(R),
Gzip(GzDecoder<R>),
Zstd(zstd::stream::Decoder<'static, BufReader<R>>),
}
impl<R: Read> Read for Decoder<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Decoder::None(r) => r.read(buf),
Decoder::Gzip(d) => d.read(buf),
Decoder::Zstd(d) => d.read(buf),
}
}
}
pub struct Archive<R: Read> {
inner: TarArchive<Decoder<R>>,
compression: Compression,
}
impl Archive<BufReader<File>> {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
reader.seek(SeekFrom::Start(0))?;
let compression = Compression::from_magic(&magic)
.or_else(|| Compression::from_extension(path))
.unwrap_or(Compression::Gzip);
Archive::with_compression(reader, compression)
}
}
impl<R: Read> Archive<R> {
#[must_use = "creating an archive has no effect if not used"]
pub fn new(reader: R) -> Result<Self> {
Self::with_compression(reader, Compression::Gzip)
}
#[must_use = "creating an archive has no effect if not used"]
pub fn with_compression(
reader: R,
compression: Compression,
) -> Result<Self> {
let decoder = match compression {
Compression::None => Decoder::None(reader),
Compression::Gzip => Decoder::Gzip(GzDecoder::new(reader)),
Compression::Zstd => {
Decoder::Zstd(zstd::stream::Decoder::new(reader)?)
}
};
Ok(Archive {
inner: TarArchive::new(decoder),
compression,
})
}
#[must_use]
pub fn compression(&self) -> Compression {
self.compression
}
#[must_use = "entries iterator must be used to iterate"]
pub fn entries(&mut self) -> Result<Entries<'_, Decoder<R>>> {
Ok(self.inner.entries()?)
}
}
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct SummaryOptions {
pub compute_file_cksum: bool,
}
#[derive(Debug)]
pub struct BinaryPackage {
path: PathBuf,
compression: Compression,
archive_type: ArchiveType,
metadata: Metadata,
plist: Plist,
build_info: HashMap<String, Vec<String>>,
pkg_hash: Option<PkgHash>,
gpg_signature: Option<Vec<u8>>,
file_size: u64,
}
impl BinaryPackage {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let file = File::open(path)?;
let file_size = file.metadata()?.len();
let mut reader = BufReader::new(file);
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
reader.seek(SeekFrom::Start(0))?;
if &magic[..7] == b"!<arch>" {
Self::read_signed(path, reader, file_size)
} else {
Self::read_unsigned(path, reader, &magic, file_size)
}
}
fn read_unsigned<R: Read + Seek>(
path: &Path,
reader: R,
magic: &[u8],
file_size: u64,
) -> Result<Self> {
let compression = Compression::from_magic(magic)
.or_else(|| Compression::from_extension(path))
.unwrap_or(Compression::Gzip);
let decompressed: Box<dyn Read> = match compression {
Compression::None => Box::new(reader),
Compression::Gzip => Box::new(GzDecoder::new(reader)),
Compression::Zstd => Box::new(zstd::stream::Decoder::new(reader)?),
};
let mut archive = TarArchive::new(decompressed);
let mut metadata = Metadata::new();
let mut plist = Plist::new();
let mut build_info: HashMap<String, Vec<String>> = HashMap::new();
for entry_result in archive.entries()? {
let mut entry = entry_result?;
let entry_path = entry.path()?.into_owned();
let Some(entry_type) =
entry_path.to_str().and_then(Entry::from_filename)
else {
break;
};
let entry_size = entry.header().size().unwrap_or(0) as usize;
let mut content = String::with_capacity(entry_size);
entry.read_to_string(&mut content)?;
metadata.read_metadata(entry_type, &content).map_err(|e| {
ArchiveError::InvalidMetadata(format!(
"{}: {}",
entry_path.display(),
e
))
})?;
if entry_path.as_os_str() == "+CONTENTS" {
plist = Plist::from_bytes(content.as_bytes())?;
} else if entry_path.as_os_str() == "+BUILD_INFO" {
for line in content.lines() {
if let Some((key, value)) = line.split_once('=') {
build_info
.entry(key.to_string())
.or_default()
.push(value.to_string());
}
}
}
}
metadata.validate().map_err(|e| {
ArchiveError::MissingMetadata(format!("incomplete package: {}", e))
})?;
Ok(Self {
path: path.to_path_buf(),
compression,
archive_type: ArchiveType::Unsigned,
metadata,
plist,
build_info,
pkg_hash: None,
gpg_signature: None,
file_size,
})
}
fn read_signed<R: Read>(
path: &Path,
reader: R,
file_size: u64,
) -> Result<Self> {
let mut ar = ar::Archive::new(reader);
let mut pkg_hash_content: Option<String> = None;
let mut gpg_signature: Option<Vec<u8>> = None;
let mut metadata = Metadata::new();
let mut plist = Plist::new();
let mut build_info: HashMap<String, Vec<String>> = HashMap::new();
let mut compression = Compression::Gzip;
loop {
let mut entry = match ar.next_entry() {
Some(Ok(entry)) => entry,
Some(Err(e)) if e.kind() == io::ErrorKind::UnexpectedEof => {
break;
}
Some(Err(e)) => return Err(e.into()),
None => break,
};
let name = String::from_utf8_lossy(entry.header().identifier())
.to_string();
match name.as_str() {
"+PKG_HASH" => {
let mut content = String::new();
entry.read_to_string(&mut content)?;
pkg_hash_content = Some(content);
}
"+PKG_GPG_SIGNATURE" => {
let mut data = Vec::new();
entry.read_to_end(&mut data)?;
gpg_signature = Some(data);
}
_ if name.ends_with(".tgz")
|| name.ends_with(".tzst")
|| name.ends_with(".tar") =>
{
compression = Compression::from_extension(&name)
.unwrap_or(Compression::Gzip);
let decompressed: Box<dyn Read> = match compression {
Compression::None => Box::new(entry),
Compression::Gzip => Box::new(GzDecoder::new(entry)),
Compression::Zstd => {
Box::new(zstd::stream::Decoder::new(entry)?)
}
};
let mut archive = TarArchive::new(decompressed);
for tar_entry_result in archive.entries()? {
let mut tar_entry = tar_entry_result?;
let entry_path = tar_entry.path()?.into_owned();
let Some(entry_type) =
entry_path.to_str().and_then(Entry::from_filename)
else {
break;
};
let entry_size =
tar_entry.header().size().unwrap_or(0) as usize;
let mut content = String::with_capacity(entry_size);
tar_entry.read_to_string(&mut content)?;
metadata.read_metadata(entry_type, &content).map_err(
|e| {
ArchiveError::InvalidMetadata(format!(
"{}: {}",
entry_path.display(),
e
))
},
)?;
if entry_path.as_os_str() == "+CONTENTS" {
plist = Plist::from_bytes(content.as_bytes())?;
} else if entry_path.as_os_str() == "+BUILD_INFO" {
for line in content.lines() {
if let Some((key, value)) = line.split_once('=')
{
build_info
.entry(key.to_string())
.or_default()
.push(value.to_string());
}
}
}
}
break;
}
_ => {}
}
}
let pkg_hash: Option<PkgHash> =
pkg_hash_content.as_deref().map(str::parse).transpose()?;
metadata.validate().map_err(|e| {
ArchiveError::MissingMetadata(format!("incomplete package: {}", e))
})?;
Ok(Self {
path: path.to_path_buf(),
compression,
archive_type: ArchiveType::Signed,
metadata,
plist,
build_info,
pkg_hash,
gpg_signature,
file_size,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn compression(&self) -> Compression {
self.compression
}
#[must_use]
pub fn archive_type(&self) -> ArchiveType {
self.archive_type
}
#[must_use]
pub fn is_signed(&self) -> bool {
self.archive_type == ArchiveType::Signed
}
#[must_use]
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
#[must_use]
pub fn plist(&self) -> &Plist {
&self.plist
}
#[must_use]
pub fn pkgname(&self) -> Option<&str> {
self.plist.pkgname()
}
#[must_use]
pub fn build_info(&self) -> &HashMap<String, Vec<String>> {
&self.build_info
}
#[must_use]
pub fn build_info_value(&self, key: &str) -> Option<&str> {
self.build_info
.get(key)
.and_then(|v| v.first())
.map(|s| s.as_str())
}
#[must_use]
pub fn build_info_values(&self, key: &str) -> Option<&[String]> {
self.build_info.get(key).map(|v| v.as_slice())
}
#[must_use]
pub fn pkg_hash(&self) -> Option<&PkgHash> {
self.pkg_hash.as_ref()
}
#[must_use]
pub fn gpg_signature(&self) -> Option<&[u8]> {
self.gpg_signature.as_deref()
}
#[must_use]
pub fn file_size(&self) -> u64 {
self.file_size
}
pub fn archive(&self) -> Result<Archive<BufReader<File>>> {
Archive::open(&self.path)
}
pub fn extract_to(&self, dest: impl AsRef<Path>) -> Result<()> {
let mut archive = self.archive()?;
for entry in archive.entries()? {
let mut entry = entry?;
entry.unpack_in(dest.as_ref())?;
}
Ok(())
}
#[cfg(unix)]
pub fn extract_with_plist(
&self,
dest: impl AsRef<Path>,
options: ExtractOptions,
) -> Result<Vec<ExtractedFile>> {
use crate::plist::FileInfo;
use std::os::unix::ffi::OsStrExt;
let dest = dest.as_ref();
let mut extracted = Vec::new();
let file_infos: HashMap<OsString, FileInfo> = self
.plist
.files_with_info()
.into_iter()
.map(|info| (info.path.clone(), info))
.collect();
let mut archive = self.archive()?;
for entry_result in archive.entries()? {
let mut entry = entry_result?;
let entry_path = entry.path()?.into_owned();
let is_metadata =
entry_path.as_os_str().as_bytes().starts_with(b"+");
entry.unpack_in(dest)?;
let full_path = dest.join(&entry_path);
let file_info = file_infos.get(entry_path.as_os_str());
let mut applied_mode = None;
if options.apply_mode && !is_metadata {
if let Some(info) = file_info {
if let Some(mode_str) = &info.mode {
if let Some(mode) = parse_mode(mode_str) {
if full_path.exists() && !full_path.is_symlink() {
fs::set_permissions(
&full_path,
Permissions::from_mode(mode),
)?;
applied_mode = Some(mode);
}
}
}
}
}
#[cfg(unix)]
if options.apply_ownership && !is_metadata {
if let Some(info) = file_info {
if info.owner.is_some() || info.group.is_some() {
}
}
}
extracted.push(ExtractedFile {
path: full_path,
is_metadata,
expected_checksum: file_info.and_then(|i| i.checksum.clone()),
mode: applied_mode,
});
}
Ok(extracted)
}
pub fn verify_checksums(
&self,
dest: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, String, String)>> {
use md5::{Digest, Md5};
let dest = dest.as_ref();
let mut failures = Vec::new();
for info in self.plist.files_with_info() {
let Some(expected) = &info.checksum else {
continue;
};
if info.symlink_target.is_some() {
continue;
}
let file_path = dest.join(&info.path);
if !file_path.exists() {
failures.push((
file_path,
expected.clone(),
"FILE_NOT_FOUND".to_string(),
));
continue;
}
let mut file = File::open(&file_path)?;
let mut hasher = Md5::new();
io::copy(&mut file, &mut hasher)?;
let result = hasher.finalize();
let actual = format!("{:032x}", result);
if actual != *expected {
failures.push((file_path, expected.clone(), actual));
}
}
Ok(failures)
}
pub fn sign(&self, signature: &[u8]) -> Result<SignedArchive> {
let pkgname = self
.pkgname()
.ok_or_else(|| ArchiveError::MissingMetadata("pkgname".into()))?
.to_string();
let tarball = std::fs::read(&self.path)?;
let pkg_hash = PkgHash::from_tarball(
&pkgname,
Cursor::new(&tarball),
PkgHashAlgorithm::Sha512,
DEFAULT_BLOCK_SIZE,
)?;
Ok(SignedArchive {
pkgname,
compression: self.compression,
pkg_hash,
signature: signature.to_vec(),
tarball,
})
}
pub fn to_summary(&self) -> Result<Summary> {
self.to_summary_with_opts(&SummaryOptions::default())
}
pub fn to_summary_with_opts(
&self,
opts: &SummaryOptions,
) -> Result<Summary> {
use sha2::{Digest, Sha256};
let pkgname = self
.plist
.pkgname()
.map(crate::PkgName::new)
.ok_or_else(|| ArchiveError::MissingMetadata("PKGNAME".into()))?;
let non_empty = |s: &&str| !s.trim().is_empty();
let to_string = |s: &str| String::from(s);
let file_cksum = if opts.compute_file_cksum && self.file_size > 0 {
let mut file = File::open(&self.path)?;
let mut hasher = Sha256::new();
io::copy(&mut file, &mut hasher)?;
let hash = hasher.finalize();
const PREFIX: &str = "sha256 ";
let mut s = String::with_capacity(PREFIX.len() + hash.len() * 2);
s.push_str(PREFIX);
for b in &hash {
let _ = write!(s, "{b:02x}");
}
Some(s)
} else {
None
};
Ok(Summary::new(
pkgname,
self.metadata.comment().to_string(),
self.metadata.size_pkg().unwrap_or(0),
to_string(self.build_info_value("BUILD_DATE").unwrap_or("")),
self.build_info_value("CATEGORIES")
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect(),
to_string(self.build_info_value("MACHINE_ARCH").unwrap_or("")),
to_string(self.build_info_value("OPSYS").unwrap_or("")),
to_string(self.build_info_value("OS_VERSION").unwrap_or("")),
to_string(self.build_info_value("PKGPATH").unwrap_or("")),
to_string(self.build_info_value("PKGTOOLS_VERSION").unwrap_or("")),
self.metadata.desc().lines().map(String::from).collect(),
Some(self.plist.conflicts().map(String::from).collect::<Vec<_>>())
.filter(|v| !v.is_empty()),
Some(self.plist.depends().map(String::from).collect::<Vec<_>>())
.filter(|v| !v.is_empty()),
self.build_info_value("HOMEPAGE")
.filter(non_empty)
.map(to_string),
self.build_info_value("LICENSE").map(to_string),
self.build_info_value("PKG_OPTIONS").map(to_string),
self.build_info_value("PREV_PKGPATH")
.filter(non_empty)
.map(to_string),
self.build_info_values("PROVIDES").map(|v| v.to_vec()),
self.build_info_values("REQUIRES").map(|v| v.to_vec()),
self.build_info_values("SUPERSEDES").map(|v| v.to_vec()),
self.path
.file_name()
.map(|f| f.to_string_lossy().into_owned()),
if self.file_size > 0 {
Some(self.file_size)
} else {
None
},
file_cksum,
))
}
}
impl FileRead for BinaryPackage {
fn pkgname(&self) -> &str {
self.plist.pkgname().unwrap_or("")
}
fn comment(&self) -> std::io::Result<String> {
Ok(self.metadata.comment().to_string())
}
fn contents(&self) -> std::io::Result<String> {
Ok(self.metadata.contents().to_string())
}
fn desc(&self) -> std::io::Result<String> {
Ok(self.metadata.desc().to_string())
}
fn build_info(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.build_info().map(|v| v.join("\n")))
}
fn build_version(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.build_version().map(|v| v.join("\n")))
}
fn deinstall(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.deinstall().map(|s| s.to_string()))
}
fn display(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.display().map(|s| s.to_string()))
}
fn install(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.install().map(|s| s.to_string()))
}
fn installed_info(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.installed_info().map(|v| v.join("\n")))
}
fn mtree_dirs(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.mtree_dirs().map(|v| v.join("\n")))
}
fn preserve(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.preserve().map(|v| v.join("\n")))
}
fn required_by(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.required_by().map(|v| v.join("\n")))
}
fn size_all(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.size_all().map(|n| n.to_string()))
}
fn size_pkg(&self) -> std::io::Result<Option<String>> {
Ok(self.metadata.size_pkg().map(|n| n.to_string()))
}
}
impl TryFrom<&BinaryPackage> for Summary {
type Error = ArchiveError;
fn try_from(pkg: &BinaryPackage) -> Result<Self> {
pkg.to_summary()
}
}
enum Encoder<W: Write> {
Gzip(GzEncoder<W>),
Zstd(zstd::stream::Encoder<'static, W>),
}
impl<W: Write> Write for Encoder<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Encoder::Gzip(e) => e.write(buf),
Encoder::Zstd(e) => e.write(buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Encoder::Gzip(e) => e.flush(),
Encoder::Zstd(e) => e.flush(),
}
}
}
impl<W: Write> Encoder<W> {
fn finish(self) -> io::Result<W> {
match self {
Encoder::Gzip(e) => e.finish(),
Encoder::Zstd(e) => e.finish(),
}
}
}
pub struct Builder<W: Write> {
inner: TarBuilder<Encoder<W>>,
compression: Compression,
}
impl Builder<File> {
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let compression =
Compression::from_extension(path).unwrap_or(Compression::Gzip);
let file = File::create(path)?;
Self::with_compression(file, compression)
}
}
impl<W: Write> Builder<W> {
pub fn new(writer: W) -> Result<Self> {
Self::with_compression(writer, Compression::Gzip)
}
pub fn with_compression(
writer: W,
compression: Compression,
) -> Result<Self> {
let encoder = match compression {
Compression::Gzip => Encoder::Gzip(GzEncoder::new(
writer,
flate2::Compression::default(),
)),
Compression::Zstd => Encoder::Zstd(zstd::stream::Encoder::new(
writer,
zstd::DEFAULT_COMPRESSION_LEVEL,
)?),
Compression::None => {
return Err(ArchiveError::UnsupportedCompression(
"uncompressed archives not supported for building".into(),
));
}
};
Ok(Self {
inner: TarBuilder::new(encoder),
compression,
})
}
#[must_use]
pub fn compression(&self) -> Compression {
self.compression
}
pub fn append_metadata_file(
&mut self,
name: &str,
content: &[u8],
) -> Result<()> {
let mut header = Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
self.inner.append_data(&mut header, name, content)?;
Ok(())
}
pub fn append_file(
&mut self,
path: impl AsRef<Path>,
content: &[u8],
mode: u32,
) -> Result<()> {
let mut header = Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(mode);
header.set_mtime(0);
header.set_cksum();
self.inner.append_data(&mut header, path, content)?;
Ok(())
}
pub fn append_path(&mut self, path: impl AsRef<Path>) -> Result<()> {
self.inner.append_path(path)?;
Ok(())
}
pub fn finish(self) -> Result<W> {
let encoder = self.inner.into_inner()?;
let writer = encoder.finish()?;
Ok(writer)
}
}
#[derive(Debug)]
pub struct SignedArchive {
pkgname: String,
compression: Compression,
pkg_hash: PkgHash,
signature: Vec<u8>,
tarball: Vec<u8>,
}
impl SignedArchive {
pub fn from_unsigned(
data: Vec<u8>,
pkgname: impl Into<String>,
signature: &[u8],
compression: Compression,
) -> Result<Self> {
let pkgname = pkgname.into();
let pkg_hash = PkgHash::from_tarball(
&pkgname,
Cursor::new(&data),
PkgHashAlgorithm::Sha512,
DEFAULT_BLOCK_SIZE,
)?;
Ok(Self {
pkgname,
compression,
pkg_hash,
signature: signature.to_vec(),
tarball: data,
})
}
#[must_use]
pub fn pkgname(&self) -> &str {
&self.pkgname
}
#[must_use]
pub fn compression(&self) -> Compression {
self.compression
}
#[must_use]
pub fn pkg_hash(&self) -> &PkgHash {
&self.pkg_hash
}
pub fn write_to(&self, path: impl AsRef<Path>) -> Result<()> {
let file = File::create(path)?;
self.write(file)
}
pub fn write<W: Write>(&self, writer: W) -> Result<()> {
let mut ar = ar::Builder::new(writer);
let hash_content = self.pkg_hash.to_string();
let hash_bytes = hash_content.as_bytes();
let mut header =
ar::Header::new(b"+PKG_HASH".to_vec(), hash_bytes.len() as u64);
header.set_mode(0o644);
ar.append(&header, hash_bytes)?;
let mut header = ar::Header::new(
b"+PKG_GPG_SIGNATURE".to_vec(),
self.signature.len() as u64,
);
header.set_mode(0o644);
ar.append(&header, self.signature.as_slice())?;
let tarball_name =
format!("{}.{}", self.pkgname, self.compression.extension());
let mut header = ar::Header::new(
tarball_name.into_bytes(),
self.tarball.len() as u64,
);
header.set_mode(0o644);
ar.append(&header, self.tarball.as_slice())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_compression_from_magic() {
assert_eq!(
Compression::from_magic(&[0x1f, 0x8b, 0, 0, 0, 0]),
Some(Compression::Gzip)
);
assert_eq!(
Compression::from_magic(&[0x28, 0xb5, 0x2f, 0xfd, 0, 0]),
Some(Compression::Zstd)
);
assert_eq!(Compression::from_magic(&[0, 0, 0, 0, 0, 0]), None);
}
#[test]
fn test_compression_from_extension() {
assert_eq!(
Compression::from_extension("foo.tgz"),
Some(Compression::Gzip)
);
assert_eq!(
Compression::from_extension("foo.tar.gz"),
Some(Compression::Gzip)
);
assert_eq!(
Compression::from_extension("foo.tzst"),
Some(Compression::Zstd)
);
assert_eq!(
Compression::from_extension("foo.tar.zst"),
Some(Compression::Zstd)
);
assert_eq!(
Compression::from_extension("foo.tar"),
Some(Compression::None)
);
}
#[test]
fn test_hash_algorithm() {
assert_eq!(
"SHA512".parse::<PkgHashAlgorithm>().ok(),
Some(PkgHashAlgorithm::Sha512)
);
assert_eq!(
"sha256".parse::<PkgHashAlgorithm>().ok(),
Some(PkgHashAlgorithm::Sha256)
);
assert!("MD5".parse::<PkgHashAlgorithm>().is_err());
assert_eq!(PkgHashAlgorithm::Sha512.as_str(), "SHA512");
assert_eq!(PkgHashAlgorithm::Sha256.as_str(), "SHA256");
assert_eq!(PkgHashAlgorithm::Sha512.hash_size(), 64);
assert_eq!(PkgHashAlgorithm::Sha256.hash_size(), 32);
}
#[test]
fn test_pkg_hash_parse() -> Result<()> {
let content = "\
pkgsrc signature
version: 1
pkgname: test-1.0
algorithm: SHA512
block size: 65536
file size: 12345
abc123
def456
";
let pkg_hash: PkgHash = content.parse()?;
assert_eq!(pkg_hash.version(), 1);
assert_eq!(pkg_hash.pkgname(), "test-1.0");
assert_eq!(pkg_hash.algorithm(), PkgHashAlgorithm::Sha512);
assert_eq!(pkg_hash.block_size(), 65536);
assert_eq!(pkg_hash.file_size(), 12345);
assert_eq!(pkg_hash.hashes(), &["abc123", "def456"]);
Ok(())
}
#[test]
fn test_pkg_hash_generate() -> Result<()> {
let data = b"Hello, World!";
let pkg_hash = PkgHash::from_tarball(
"test-1.0",
Cursor::new(data),
PkgHashAlgorithm::Sha512,
1024,
)?;
assert_eq!(pkg_hash.pkgname(), "test-1.0");
assert_eq!(pkg_hash.algorithm(), PkgHashAlgorithm::Sha512);
assert_eq!(pkg_hash.block_size(), 1024);
assert_eq!(pkg_hash.file_size(), 13);
assert_eq!(pkg_hash.hashes().len(), 1);
Ok(())
}
#[test]
fn test_pkg_hash_verify() -> Result<()> {
let data = b"Hello, World!";
let pkg_hash = PkgHash::from_tarball(
"test-1.0",
Cursor::new(data),
PkgHashAlgorithm::Sha512,
1024,
)?;
assert!(pkg_hash.verify(Cursor::new(data))?);
let bad_data = b"Goodbye, World!";
assert!(pkg_hash.verify(Cursor::new(bad_data)).is_err());
Ok(())
}
#[test]
fn test_pkg_hash_roundtrip() -> Result<()> {
let data = vec![0u8; 200_000];
let pkg_hash = PkgHash::from_tarball(
"test-1.0",
Cursor::new(&data),
PkgHashAlgorithm::Sha512,
65536,
)?;
let serialized = pkg_hash.to_string();
let parsed: PkgHash = serialized.parse()?;
assert_eq!(pkg_hash.version(), parsed.version());
assert_eq!(pkg_hash.pkgname(), parsed.pkgname());
assert_eq!(pkg_hash.algorithm(), parsed.algorithm());
assert_eq!(pkg_hash.block_size(), parsed.block_size());
assert_eq!(pkg_hash.file_size(), parsed.file_size());
assert_eq!(pkg_hash.hashes(), parsed.hashes());
assert!(parsed.verify(Cursor::new(&data))?);
Ok(())
}
#[test]
fn test_build_package_gzip() -> Result<()> {
let mut builder = Builder::new(Vec::new())?;
let plist = "@name testpkg-1.0\n@cwd /opt/test\nbin/test\n";
builder.append_metadata_file("+CONTENTS", plist.as_bytes())?;
builder.append_metadata_file("+COMMENT", b"A test package")?;
builder.append_metadata_file(
"+DESC",
b"This is a test.\nMultiple lines.",
)?;
builder.append_metadata_file(
"+BUILD_INFO",
b"OPSYS=NetBSD\nMACHINE_ARCH=x86_64\n",
)?;
builder.append_file("bin/test", b"#!/bin/sh\necho test", 0o755)?;
let output = builder.finish()?;
assert!(!output.is_empty());
let mut archive = Archive::new(Cursor::new(&output))?;
let mut found_contents = false;
for entry in archive.entries()? {
let entry = entry?;
if entry.path()?.to_str() == Some("+CONTENTS") {
found_contents = true;
break;
}
}
assert!(found_contents);
Ok(())
}
#[test]
fn test_build_package_zstd() -> Result<()> {
let mut builder =
Builder::with_compression(Vec::new(), Compression::Zstd)?;
let plist = "@name testpkg-1.0\n@cwd /opt/test\nbin/test\n";
builder.append_metadata_file("+CONTENTS", plist.as_bytes())?;
builder.append_metadata_file("+COMMENT", b"A test package")?;
builder.append_metadata_file(
"+DESC",
b"This is a test.\nMultiple lines.",
)?;
builder.append_file("bin/test", b"#!/bin/sh\necho test", 0o755)?;
let output = builder.finish()?;
assert!(!output.is_empty());
let mut archive =
Archive::with_compression(Cursor::new(&output), Compression::Zstd)?;
let mut found_contents = false;
for entry in archive.entries()? {
let entry = entry?;
if entry.path()?.to_str() == Some("+CONTENTS") {
found_contents = true;
break;
}
}
assert!(found_contents);
Ok(())
}
#[test]
fn test_signed_archive_from_unsigned() -> Result<()> {
let mut builder = Builder::new(Vec::new())?;
builder.append_metadata_file("+CONTENTS", b"@name testpkg-1.0\n")?;
builder.append_metadata_file("+COMMENT", b"A test package")?;
builder.append_metadata_file("+DESC", b"Test description")?;
let output = builder.finish()?;
let fake_signature = b"FAKE GPG SIGNATURE";
let signed = SignedArchive::from_unsigned(
output,
"testpkg-1.0",
fake_signature,
Compression::Gzip,
)?;
assert_eq!(signed.pkgname(), "testpkg-1.0");
assert_eq!(signed.pkg_hash().algorithm(), PkgHashAlgorithm::Sha512);
assert_eq!(signed.compression(), Compression::Gzip);
let mut signed_output = Vec::new();
signed.write(&mut signed_output)?;
assert!(&signed_output[..7] == b"!<arch>");
Ok(())
}
#[test]
fn test_signed_archive_zstd() -> Result<()> {
let mut builder =
Builder::with_compression(Vec::new(), Compression::Zstd)?;
builder.append_metadata_file("+CONTENTS", b"@name testpkg-1.0\n")?;
builder.append_metadata_file("+COMMENT", b"A test package")?;
builder.append_metadata_file("+DESC", b"Test description")?;
let output = builder.finish()?;
let fake_signature = b"FAKE GPG SIGNATURE";
let signed = SignedArchive::from_unsigned(
output,
"testpkg-1.0",
fake_signature,
Compression::Zstd,
)?;
assert_eq!(signed.pkgname(), "testpkg-1.0");
assert_eq!(signed.compression(), Compression::Zstd);
let mut signed_output = Vec::new();
signed.write(&mut signed_output)?;
assert!(&signed_output[..7] == b"!<arch>");
Ok(())
}
#[test]
fn test_parse_mode() {
assert_eq!(super::parse_mode("0755"), Some(0o755));
assert_eq!(super::parse_mode("755"), Some(0o755));
assert_eq!(super::parse_mode("0644"), Some(0o644));
assert_eq!(super::parse_mode("644"), Some(0o644));
assert_eq!(super::parse_mode("0777"), Some(0o777));
assert_eq!(super::parse_mode("0400"), Some(0o400));
assert_eq!(super::parse_mode(""), None);
assert_eq!(super::parse_mode("abc"), None);
assert_eq!(super::parse_mode("999"), None); }
#[test]
fn test_extract_options() {
let opts = ExtractOptions::new();
assert!(!opts.apply_mode);
assert!(!opts.apply_ownership);
assert!(!opts.preserve_mtime);
let opts = ExtractOptions::new().with_mode().with_ownership();
assert!(opts.apply_mode);
assert!(opts.apply_ownership);
assert!(!opts.preserve_mtime);
}
}