a774c61e5a
- Added an `interrupts` module to manage Ctrl-C signals and propagate them to child processes. - Refactored archive extraction functions to check for interrupts and handle them gracefully. - Introduced `copy_interruptibly` function to allow for interruptible copying of data. - Updated various source files to utilize the new interrupt handling functions, ensuring that commands and archive extractions can be interrupted by the user. - Added tests to verify the interrupt functionality during copying and tar extraction. - Introduced a new Meson option for generating HTML CLI documentation. - Enhanced the `PackageSpec` struct to support additional Rust LTO flags.
1082 lines
37 KiB
Rust
Executable File
1082 lines
37 KiB
Rust
Executable File
//! Archive extraction support
|
|
|
|
use crate::package::{PackageSpec, Source};
|
|
use anyhow::{Context, Result, bail};
|
|
use filetime::FileTime;
|
|
use flate2::read::GzDecoder;
|
|
use lz4_flex::frame::FrameDecoder as Lz4FrameDecoder;
|
|
use lzma_rust2::{LzipReader, LzmaReader};
|
|
use std::fs::{self, File};
|
|
use std::io::{Cursor, Read, Write};
|
|
use std::os::unix::fs as unix_fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use tempfile::{NamedTempFile, tempdir};
|
|
use walkdir::WalkDir;
|
|
use zstd::stream::read::Decoder as ZstdDecoder;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ArchiveFormat {
|
|
TarGz,
|
|
TarXz,
|
|
TarBz2,
|
|
TarZst,
|
|
TarLz4,
|
|
TarLzma,
|
|
TarLzip,
|
|
TarCompress,
|
|
Zip,
|
|
Tar,
|
|
Deb,
|
|
Rpm,
|
|
Cpio,
|
|
GzFile,
|
|
XzFile,
|
|
ZstFile,
|
|
}
|
|
|
|
/// Extract an archive source to the build directory.
|
|
pub fn extract_archive(
|
|
archive_path: &Path,
|
|
spec: &PackageSpec,
|
|
source: &Source,
|
|
build_dir: &Path,
|
|
) -> Result<PathBuf> {
|
|
crate::interrupts::install()?;
|
|
let extract_dir_name = spec.expand_vars(&source.extract_dir);
|
|
let extract_path = build_dir.join(&extract_dir_name);
|
|
|
|
// Create build directory
|
|
fs::create_dir_all(build_dir)
|
|
.with_context(|| format!("Failed to create build dir: {}", build_dir.display()))?;
|
|
|
|
// Remove existing extraction if present
|
|
if extract_path.exists() {
|
|
fs::remove_dir_all(&extract_path)?;
|
|
}
|
|
|
|
let filename = archive_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("");
|
|
|
|
crate::log_info!("Extracting: {}", filename);
|
|
|
|
match archive_format_for_filename(filename) {
|
|
Some(ArchiveFormat::TarGz) => extract_tar_gz(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarXz) => extract_tar_xz(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarBz2) => extract_tar_bz2(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarZst) => extract_tar_zst(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarLz4) => extract_tar_lz4(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarLzma) => extract_tar_lzma(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarLzip) => extract_tar_lzip(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::TarCompress) => extract_tar_compress(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::Zip) => extract_zip(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::Tar) => extract_tar(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::Deb) => extract_deb(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::Rpm) => extract_rpm(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::Cpio) => extract_cpio(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::GzFile) => extract_gz_file(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::XzFile) => extract_xz_file(archive_path, &extract_path)?,
|
|
Some(ArchiveFormat::ZstFile) => extract_zst_file(archive_path, &extract_path)?,
|
|
None => bail!("Unsupported archive format: {}", filename),
|
|
}
|
|
|
|
if !extract_path.exists() {
|
|
bail!(
|
|
"Extraction did not create expected path: {}",
|
|
extract_path.display()
|
|
);
|
|
}
|
|
|
|
crate::log_info!("Extracted to: {}", extract_path.display());
|
|
Ok(extract_path)
|
|
}
|
|
|
|
fn archive_format_for_filename(filename: &str) -> Option<ArchiveFormat> {
|
|
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
|
Some(ArchiveFormat::TarGz)
|
|
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
|
Some(ArchiveFormat::TarXz)
|
|
} else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") {
|
|
Some(ArchiveFormat::TarBz2)
|
|
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tzst") {
|
|
Some(ArchiveFormat::TarZst)
|
|
} else if filename.ends_with(".tar.lz4") {
|
|
Some(ArchiveFormat::TarLz4)
|
|
} else if filename.ends_with(".tar.lzma") {
|
|
Some(ArchiveFormat::TarLzma)
|
|
} else if filename.ends_with(".tar.lz") {
|
|
Some(ArchiveFormat::TarLzip)
|
|
} else if filename.ends_with(".tar.Z") {
|
|
Some(ArchiveFormat::TarCompress)
|
|
} else if filename.ends_with(".zip") {
|
|
Some(ArchiveFormat::Zip)
|
|
} else if filename.ends_with(".tar") {
|
|
Some(ArchiveFormat::Tar)
|
|
} else if filename.ends_with(".deb") {
|
|
Some(ArchiveFormat::Deb)
|
|
} else if filename.ends_with(".rpm") {
|
|
Some(ArchiveFormat::Rpm)
|
|
} else if filename.ends_with(".cpio") {
|
|
Some(ArchiveFormat::Cpio)
|
|
} else if filename.ends_with(".gz") {
|
|
Some(ArchiveFormat::GzFile)
|
|
} else if filename.ends_with(".xz") {
|
|
Some(ArchiveFormat::XzFile)
|
|
} else if filename.ends_with(".zst") {
|
|
Some(ArchiveFormat::ZstFile)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = GzDecoder::new(file);
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = xz2::read::XzDecoder::new(file);
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = bzip2::read::BzDecoder::new(file);
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
extract_tar_reader(file, dest)
|
|
}
|
|
|
|
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let file = File::open(path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
extract_zip_archive(&mut archive, tmp.path())?;
|
|
finalize_extracted_tree(tmp.path(), dest)
|
|
}
|
|
|
|
fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = ZstdDecoder::new(file)?;
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_lz4(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = Lz4FrameDecoder::new(file);
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_lzma(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = LzmaReader::new_mem_limit(file, u32::MAX, None)?;
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_lzip(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let decoder = LzipReader::new(file);
|
|
extract_tar_reader(decoder, dest)
|
|
}
|
|
|
|
fn extract_tar_compress(path: &Path, dest: &Path) -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let mut child = Command::new("gzip");
|
|
child.arg("-cd").arg(path);
|
|
child.stdout(Stdio::piped());
|
|
crate::interrupts::check()?;
|
|
// Run gzip in its own process group so Ctrl-C can interrupt decompression too.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
unsafe {
|
|
child.pre_exec(|| {
|
|
if nix::libc::setpgid(0, 0) != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
let mut child = child.spawn().with_context(|| {
|
|
format!(
|
|
"Failed to spawn gzip for .Z decompression: {}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.context("Failed to capture gzip stdout")?;
|
|
let mut archive = tar::Archive::new(stdout);
|
|
let unpack_result = crate::interrupts::unpack_tar_archive(&mut archive, tmp.path())
|
|
.with_context(|| format!("Failed to unpack .tar.Z archive {}", path.display()));
|
|
drop(archive);
|
|
let status = wait_for_child_interruptibly(&mut child)
|
|
.with_context(|| format!("Failed waiting for gzip on {}", path.display()))?;
|
|
unpack_result?;
|
|
if !status.success() {
|
|
bail!("gzip failed while decompressing {}", path.display());
|
|
}
|
|
finalize_extracted_tree(tmp.path(), dest)
|
|
}
|
|
|
|
fn extract_cpio(path: &Path, dest: &Path) -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let file = File::open(path)?;
|
|
extract_cpio_newc_from_reader(file, tmp.path())?;
|
|
finalize_extracted_tree(tmp.path(), dest)
|
|
}
|
|
|
|
fn extract_tar_reader<R: Read>(reader: R, dest: &Path) -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let mut archive = tar::Archive::new(reader);
|
|
crate::interrupts::unpack_tar_archive(&mut archive, tmp.path())?;
|
|
finalize_extracted_tree(tmp.path(), dest)
|
|
}
|
|
|
|
fn finalize_extracted_tree(src_root: &Path, dest: &Path) -> Result<()> {
|
|
let top = fs::read_dir(src_root)?
|
|
.filter_map(|r| r.ok())
|
|
.collect::<Vec<_>>();
|
|
if top.len() == 1 && top[0].path().is_dir() {
|
|
let top_name = top[0].file_name().to_string_lossy().to_string();
|
|
let expected_basename = dest.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
|
|
let sys_blacklist = [
|
|
"usr", "bin", "sbin", "lib", "lib64", "etc", "share", "opt", "var", "run", "dev",
|
|
"proc", "sys", "boot", "srv", "home",
|
|
];
|
|
|
|
let looks_like_versioned =
|
|
|s: &str| s.contains('-') && s.chars().any(|c| c.is_ascii_digit());
|
|
let should_strip = (!sys_blacklist.contains(&top_name.as_str()))
|
|
&& (top_name == expected_basename
|
|
|| (!expected_basename.is_empty() && top_name.contains(expected_basename))
|
|
|| looks_like_versioned(&top_name));
|
|
|
|
if should_strip {
|
|
move_dir_contents(&top[0].path(), dest)?;
|
|
} else {
|
|
fs::create_dir_all(dest)?;
|
|
let dest_top = dest.join(top_name);
|
|
if fs::rename(top[0].path(), &dest_top).is_err() {
|
|
copy_dir_recursive_local(&top[0].path(), &dest_top)?;
|
|
fs::remove_dir_all(top[0].path())?;
|
|
}
|
|
}
|
|
} else {
|
|
move_dir_contents(src_root, dest)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_gz_file(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let mut decoder = GzDecoder::new(file);
|
|
|
|
let filename = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("output");
|
|
|
|
let out_name = if let Some(stripped) = filename.strip_suffix(".gz") {
|
|
stripped
|
|
} else {
|
|
filename
|
|
};
|
|
|
|
fs::create_dir_all(dest)?;
|
|
let mut out = File::create(dest.join(out_name))?;
|
|
crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_xz_file(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let mut decoder = xz2::read::XzDecoder::new(file);
|
|
|
|
let filename = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("output");
|
|
|
|
let out_name = if let Some(stripped) = filename.strip_suffix(".xz") {
|
|
stripped
|
|
} else {
|
|
filename
|
|
};
|
|
|
|
fs::create_dir_all(dest)?;
|
|
let mut out = File::create(dest.join(out_name))?;
|
|
crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_zst_file(path: &Path, dest: &Path) -> Result<()> {
|
|
let file = File::open(path)?;
|
|
let mut decoder = ZstdDecoder::new(file)?;
|
|
|
|
let filename = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("output");
|
|
|
|
let out_name = if let Some(stripped) = filename.strip_suffix(".zst") {
|
|
stripped
|
|
} else {
|
|
filename
|
|
};
|
|
|
|
fs::create_dir_all(dest)?;
|
|
let mut out = File::create(dest.join(out_name))?;
|
|
crate::interrupts::copy_interruptibly(&mut decoder, &mut out)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_deb(path: &Path, dest: &Path) -> Result<()> {
|
|
// Debian packages are ar archives containing (among others) a data.tar.* member
|
|
let file = File::open(path)?;
|
|
let mut ar = ar::Archive::new(file);
|
|
|
|
while let Some(entry_result) = ar.next_entry() {
|
|
crate::interrupts::check()?;
|
|
let mut entry = entry_result?;
|
|
let id = String::from_utf8_lossy(entry.header().identifier()).to_string();
|
|
let lower = id.to_ascii_lowercase();
|
|
if lower.starts_with("data.tar") {
|
|
// write the inner member to a temporary file and reuse tar extraction logic
|
|
let mut tmpf = NamedTempFile::new()?;
|
|
crate::interrupts::copy_interruptibly(&mut entry, &mut tmpf)?;
|
|
let tmp_path = tmpf.path().to_path_buf();
|
|
|
|
if lower.ends_with(".gz") {
|
|
extract_tar_gz(&tmp_path, dest)?;
|
|
return Ok(());
|
|
} else if lower.ends_with(".xz") {
|
|
extract_tar_xz(&tmp_path, dest)?;
|
|
return Ok(());
|
|
} else if lower.ends_with(".zst") {
|
|
extract_tar_zst(&tmp_path, dest)?;
|
|
return Ok(());
|
|
} else {
|
|
extract_tar(&tmp_path, dest)?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
anyhow::bail!("No data.tar.* member found in deb: {}", path.display());
|
|
}
|
|
|
|
fn extract_cpio_newc_from_reader<R: Read>(mut r: R, dest: &Path) -> Result<()> {
|
|
use std::str;
|
|
loop {
|
|
crate::interrupts::check()?;
|
|
// read 6-byte magic
|
|
let mut magic = [0u8; 6];
|
|
if let Err(e) = r.read_exact(&mut magic) {
|
|
// EOF
|
|
return Err(e).with_context(|| "Failed reading cpio magic")?;
|
|
}
|
|
if &magic != b"070701" {
|
|
anyhow::bail!("Unsupported cpio magic: {:?}", &magic);
|
|
}
|
|
|
|
// read remaining 104 bytes of header (total header is 110)
|
|
let mut rest = [0u8; 104];
|
|
r.read_exact(&mut rest)?;
|
|
let header_str = str::from_utf8(&rest).context("Invalid cpio header encoding")?;
|
|
// parse 13 hex fields of 8 chars each
|
|
if header_str.len() < 8 * 13 {
|
|
anyhow::bail!("Truncated cpio header");
|
|
}
|
|
let mut fields = [0u64; 13];
|
|
for i in 0..13 {
|
|
let part = &header_str[i * 8..i * 8 + 8];
|
|
fields[i] = u64::from_str_radix(part, 16)
|
|
.with_context(|| format!("Invalid header field hex: {}", part))?;
|
|
}
|
|
|
|
let namesize = fields[11] as usize;
|
|
let filesize = fields[6] as usize;
|
|
|
|
// read name
|
|
let mut name_buf = vec![0u8; namesize];
|
|
r.read_exact(&mut name_buf)?;
|
|
let name = match name_buf.iter().position(|&b| b == 0) {
|
|
Some(p) => String::from_utf8_lossy(&name_buf[..p]).to_string(),
|
|
None => String::from_utf8_lossy(&name_buf).to_string(),
|
|
};
|
|
|
|
// skip header padding to 4 bytes
|
|
let header_total = 110 + namesize;
|
|
let header_pad = (4 - (header_total % 4)) % 4;
|
|
if header_pad > 0 {
|
|
let mut tmp = vec![0u8; header_pad];
|
|
r.read_exact(&mut tmp)?;
|
|
}
|
|
|
|
if name == "TRAILER!!!" {
|
|
break;
|
|
}
|
|
|
|
let mode = fields[1];
|
|
let file_path = dest.join(&name);
|
|
|
|
if (mode & 0o170000) == 0o040000 {
|
|
// directory
|
|
fs::create_dir_all(&file_path)?;
|
|
} else if (mode & 0o170000) == 0o120000 {
|
|
// symlink
|
|
let mut link_buf = vec![0u8; filesize];
|
|
r.read_exact(&mut link_buf)?;
|
|
let target = String::from_utf8_lossy(&link_buf).to_string();
|
|
if let Some(parent) = file_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let _ = fs::remove_file(&file_path);
|
|
unix_fs::symlink(target, &file_path)?;
|
|
} else {
|
|
// regular file
|
|
if let Some(parent) = file_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut out = File::create(&file_path)?;
|
|
let mut remaining = filesize;
|
|
let mut buf = [0u8; 8192];
|
|
while remaining > 0 {
|
|
crate::interrupts::check()?;
|
|
let to_read = std::cmp::min(remaining, buf.len());
|
|
let n = r.read(&mut buf[..to_read])?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
out.write_all(&buf[..n])?;
|
|
remaining -= n;
|
|
}
|
|
// set permissions
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(
|
|
&file_path,
|
|
fs::Permissions::from_mode((mode & 0o7777) as u32),
|
|
)?;
|
|
}
|
|
|
|
// skip file data padding
|
|
let data_pad = (4 - (filesize % 4)) % 4;
|
|
if data_pad > 0 {
|
|
let mut tmp = vec![0u8; data_pad];
|
|
r.read_exact(&mut tmp)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_rpm(path: &Path, dest: &Path) -> Result<()> {
|
|
// Read entire file and search for compression/cpio magic
|
|
crate::interrupts::check()?;
|
|
let data = std::fs::read(path)?;
|
|
|
|
// search for known signatures
|
|
let gz_sig = b"\x1f\x8b";
|
|
let xz_sig = b"\xfd7zXZ\x00";
|
|
let zst_sig = b"\x28\xb5\x2f\xfd";
|
|
let cpio_sig = b"070701";
|
|
|
|
// Extract into a temporary directory first, then move/strip into `dest`.
|
|
let tmp = tempdir()?;
|
|
|
|
if let Some(pos) = find_subslice(&data, gz_sig) {
|
|
let cursor = Cursor::new(&data[pos..]);
|
|
let decoder = GzDecoder::new(cursor);
|
|
extract_cpio_newc_from_reader(decoder, tmp.path())?;
|
|
move_dir_contents(tmp.path(), dest)?;
|
|
return Ok(());
|
|
}
|
|
if let Some(pos) = find_subslice(&data, xz_sig) {
|
|
let cursor = Cursor::new(&data[pos..]);
|
|
let decoder = xz2::read::XzDecoder::new(cursor);
|
|
extract_cpio_newc_from_reader(decoder, tmp.path())?;
|
|
move_dir_contents(tmp.path(), dest)?;
|
|
return Ok(());
|
|
}
|
|
if let Some(pos) = find_subslice(&data, zst_sig) {
|
|
let cursor = Cursor::new(&data[pos..]);
|
|
let decoder = ZstdDecoder::new(cursor)?;
|
|
extract_cpio_newc_from_reader(decoder, tmp.path())?;
|
|
move_dir_contents(tmp.path(), dest)?;
|
|
return Ok(());
|
|
}
|
|
if let Some(pos) = find_subslice(&data, cpio_sig) {
|
|
let cursor = Cursor::new(&data[pos..]);
|
|
extract_cpio_newc_from_reader(cursor, tmp.path())?;
|
|
move_dir_contents(tmp.path(), dest)?;
|
|
return Ok(());
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"No recognizable cpio payload found in rpm: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
|
hay.windows(needle.len()).position(|w| w == needle)
|
|
}
|
|
|
|
/// Move contents of `src` into `dest`. If `src` contains exactly one directory
|
|
/// and `strip_single_top` is intended, callers can pass the inner dir instead.
|
|
fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> {
|
|
fs::create_dir_all(dest)?;
|
|
for entry in fs::read_dir(src)? {
|
|
crate::interrupts::check()?;
|
|
let entry = entry?;
|
|
let src_path = entry.path();
|
|
let dest_path = dest.join(entry.file_name());
|
|
let file_type = entry.file_type()?;
|
|
if fs::rename(&src_path, &dest_path).is_err() {
|
|
// fallback to copy-and-remove across filesystems
|
|
copy_entry_fallback(&src_path, &dest_path, file_type)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_entry_fallback(src_path: &Path, dest_path: &Path, file_type: fs::FileType) -> Result<()> {
|
|
if file_type.is_dir() {
|
|
copy_dir_recursive_local(src_path, dest_path)?;
|
|
fs::remove_dir_all(src_path)?;
|
|
} else if file_type.is_symlink() {
|
|
if let Some(parent) = dest_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let link_target = fs::read_link(src_path)
|
|
.with_context(|| format!("Failed to read symlink {}", src_path.display()))?;
|
|
unix_fs::symlink(&link_target, dest_path).with_context(|| {
|
|
format!(
|
|
"Failed to create symlink {} -> {}",
|
|
dest_path.display(),
|
|
link_target.display()
|
|
)
|
|
})?;
|
|
fs::remove_file(src_path)?;
|
|
} else {
|
|
copy_file_preserve_metadata(src_path, dest_path)?;
|
|
fs::remove_file(src_path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_file_preserve_metadata(src: &Path, dst: &Path) -> Result<()> {
|
|
if let Some(parent) = dst.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
fs::copy(src, dst)
|
|
.with_context(|| format!("Failed to copy {} -> {}", src.display(), dst.display()))?;
|
|
|
|
let src_meta = fs::metadata(src)
|
|
.with_context(|| format!("Failed to read metadata for {}", src.display()))?;
|
|
fs::set_permissions(dst, src_meta.permissions())
|
|
.with_context(|| format!("Failed to set permissions for {}", dst.display()))?;
|
|
|
|
let atime = FileTime::from_last_access_time(&src_meta);
|
|
let mtime = FileTime::from_last_modification_time(&src_meta);
|
|
filetime::set_file_times(dst, atime, mtime)
|
|
.with_context(|| format!("Failed to preserve file timestamps for {}", dst.display()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_dir_recursive_local(src: &Path, dst: &Path) -> Result<()> {
|
|
for entry in WalkDir::new(src) {
|
|
crate::interrupts::check()?;
|
|
let entry = entry?;
|
|
let rel = entry.path().strip_prefix(src).unwrap();
|
|
let target = dst.join(rel);
|
|
if entry.file_type().is_dir() {
|
|
fs::create_dir_all(&target)?;
|
|
} else if entry.file_type().is_symlink() {
|
|
let target_link = fs::read_link(entry.path())?;
|
|
unix_fs::symlink(target_link, &target)?;
|
|
} else {
|
|
if let Some(parent) = target.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
copy_file_preserve_metadata(entry.path(), &target)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_zip_archive<R: Read + std::io::Seek>(
|
|
archive: &mut zip::ZipArchive<R>,
|
|
dest: &Path,
|
|
) -> Result<()> {
|
|
for index in 0..archive.len() {
|
|
crate::interrupts::check()?;
|
|
let mut entry = archive.by_index(index)?;
|
|
let enclosed = entry
|
|
.enclosed_name()
|
|
.with_context(|| format!("Zip archive entry has unsafe path: {}", entry.name()))?;
|
|
let out_path = dest.join(enclosed);
|
|
|
|
if entry.is_dir() {
|
|
fs::create_dir_all(&out_path)?;
|
|
continue;
|
|
}
|
|
|
|
let mode = entry.unix_mode().unwrap_or(0);
|
|
if (mode & 0o170000) == 0o120000 {
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut target = Vec::new();
|
|
crate::interrupts::copy_interruptibly(&mut entry, &mut target)?;
|
|
let target = String::from_utf8(target).context("Zip symlink target was not UTF-8")?;
|
|
let _ = fs::remove_file(&out_path);
|
|
unix_fs::symlink(target, &out_path)?;
|
|
continue;
|
|
}
|
|
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut out = File::create(&out_path)?;
|
|
crate::interrupts::copy_interruptibly(&mut entry, &mut out)?;
|
|
if mode != 0 {
|
|
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode & 0o7777))?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn wait_for_child_interruptibly(
|
|
child: &mut std::process::Child,
|
|
) -> std::io::Result<std::process::ExitStatus> {
|
|
let mut interrupted_at = None;
|
|
let mut sent_term = false;
|
|
let mut sent_kill = false;
|
|
|
|
loop {
|
|
if let Some(status) = child.try_wait()? {
|
|
return Ok(status);
|
|
}
|
|
|
|
if crate::interrupts::was_interrupted() {
|
|
if interrupted_at.is_none() {
|
|
interrupted_at = Some(std::time::Instant::now());
|
|
signal_child_group(child, nix::libc::SIGINT);
|
|
} else if interrupted_at.is_some_and(|started| {
|
|
started.elapsed() >= std::time::Duration::from_secs(2) && !sent_term
|
|
}) {
|
|
sent_term = true;
|
|
signal_child_group(child, nix::libc::SIGTERM);
|
|
} else if interrupted_at.is_some_and(|started| {
|
|
started.elapsed() >= std::time::Duration::from_secs(4) && !sent_kill
|
|
}) {
|
|
sent_kill = true;
|
|
signal_child_group(child, nix::libc::SIGKILL);
|
|
}
|
|
}
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
fn signal_child_group(child: &std::process::Child, signal: i32) {
|
|
let rc = unsafe { nix::libc::kill(-(child.id() as i32), signal) };
|
|
if rc != 0 {
|
|
let err = std::io::Error::last_os_error();
|
|
if err.raw_os_error() != Some(nix::libc::ESRCH) {
|
|
crate::log_warn!(
|
|
"Failed to forward signal {} to extractor child process group {}: {}",
|
|
signal,
|
|
child.id(),
|
|
err
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo};
|
|
use lz4_flex::frame::FrameEncoder as Lz4FrameEncoder;
|
|
use lzma_rust2::{LzipOptions, LzipWriter, LzmaOptions, LzmaWriter};
|
|
use std::io::Write;
|
|
use std::time::{Duration, SystemTime};
|
|
use tempfile::tempdir;
|
|
|
|
fn test_spec() -> PackageSpec {
|
|
PackageSpec {
|
|
package: PackageInfo {
|
|
name: "pkg".into(),
|
|
version: "1.0".into(),
|
|
revision: 1,
|
|
description: "d".into(),
|
|
homepage: "h".into(),
|
|
license: vec!["MIT".into()],
|
|
},
|
|
packages: Vec::new(),
|
|
alternatives: Default::default(),
|
|
manual_sources: Vec::new(),
|
|
source: Vec::new(),
|
|
build: Build {
|
|
build_type: BuildType::Custom,
|
|
flags: BuildFlags::default(),
|
|
},
|
|
dependencies: Dependencies::default(),
|
|
package_alternatives: Default::default(),
|
|
package_dependencies: Default::default(),
|
|
spec_dir: PathBuf::from("."),
|
|
}
|
|
}
|
|
|
|
fn test_source(extract_dir: &str) -> Source {
|
|
Source {
|
|
url: "https://example.test/src.tar".into(),
|
|
sha256: "skip".into(),
|
|
extract_dir: extract_dir.into(),
|
|
patches: Vec::new(),
|
|
post_extract: Vec::new(),
|
|
cherry_pick: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn simple_tar_bytes(top_dir: &str, file_name: &str, contents: &[u8]) -> Vec<u8> {
|
|
let mut tar_buf = Vec::new();
|
|
{
|
|
let mut tar = tar::Builder::new(&mut tar_buf);
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(contents.len() as u64);
|
|
header.set_mode(0o644);
|
|
header.set_cksum();
|
|
tar.append_data(&mut header, format!("{top_dir}/{file_name}"), contents)
|
|
.unwrap();
|
|
tar.finish().unwrap();
|
|
}
|
|
tar_buf
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_deb_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let deb_path = tmp.path().join("test.deb");
|
|
let extract_dir = tmp.path().join("out-deb");
|
|
|
|
// create a small tar.gz payload with one file
|
|
let mut tar_buf = Vec::new();
|
|
{
|
|
let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default());
|
|
let mut tar = tar::Builder::new(gz);
|
|
let mut header = tar::Header::new_gnu();
|
|
let data = b"hello-deb";
|
|
header.set_size(data.len() as u64);
|
|
header.set_mode(0o644);
|
|
header.set_cksum();
|
|
tar.append_data(&mut header, "usr/bin/hello-deb", &data[..])
|
|
.unwrap();
|
|
tar.finish().unwrap();
|
|
}
|
|
|
|
// write ar archive with member data.tar.gz (use temp files because ar::Builder expects File)
|
|
{
|
|
let db = tmp.path().join("debian-binary");
|
|
let ct = tmp.path().join("control.tar.gz");
|
|
let dt = tmp.path().join("data.tar.gz");
|
|
std::fs::write(&db, b"2.0\n").unwrap();
|
|
std::fs::write(&ct, b"").unwrap();
|
|
std::fs::write(&dt, &tar_buf[..]).unwrap();
|
|
|
|
let mut f = File::create(&deb_path).unwrap();
|
|
let mut builder = ar::Builder::new(&mut f);
|
|
let mut dbf = File::open(&db).unwrap();
|
|
let mut ctf = File::open(&ct).unwrap();
|
|
let mut dtf = File::open(&dt).unwrap();
|
|
builder.append_file(b"debian-binary", &mut dbf).unwrap();
|
|
builder.append_file(b"control.tar.gz", &mut ctf).unwrap();
|
|
builder.append_file(b"data.tar.gz", &mut dtf).unwrap();
|
|
}
|
|
|
|
fs::create_dir_all(&extract_dir).unwrap();
|
|
extract_deb(&deb_path, &extract_dir).unwrap();
|
|
assert!(extract_dir.join("usr/bin/hello-deb").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_archive_format_for_new_extensions() {
|
|
assert_eq!(
|
|
archive_format_for_filename("pkg.tar.lz4"),
|
|
Some(ArchiveFormat::TarLz4)
|
|
);
|
|
assert_eq!(
|
|
archive_format_for_filename("pkg.tar.lzma"),
|
|
Some(ArchiveFormat::TarLzma)
|
|
);
|
|
assert_eq!(
|
|
archive_format_for_filename("pkg.tar.lz"),
|
|
Some(ArchiveFormat::TarLzip)
|
|
);
|
|
assert_eq!(
|
|
archive_format_for_filename("pkg.tar.Z"),
|
|
Some(ArchiveFormat::TarCompress)
|
|
);
|
|
assert_eq!(
|
|
archive_format_for_filename("pkg.cpio"),
|
|
Some(ArchiveFormat::Cpio)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_archive_tar_lz4_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let archive_path = tmp.path().join("pkg.tar.lz4");
|
|
let build_dir = tmp.path().join("build");
|
|
let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lz4");
|
|
|
|
let mut compressed = Vec::new();
|
|
{
|
|
let mut encoder = Lz4FrameEncoder::new(&mut compressed);
|
|
encoder.write_all(&tar_buf).unwrap();
|
|
encoder.finish().unwrap();
|
|
}
|
|
fs::write(&archive_path, compressed).unwrap();
|
|
|
|
let extracted = extract_archive(
|
|
&archive_path,
|
|
&test_spec(),
|
|
&test_source("pkg-1.0"),
|
|
&build_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("hello.txt")).unwrap(),
|
|
"hello-lz4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_archive_tar_lzma_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let archive_path = tmp.path().join("pkg.tar.lzma");
|
|
let build_dir = tmp.path().join("build");
|
|
let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzma");
|
|
|
|
let mut compressed = Vec::new();
|
|
{
|
|
let options = LzmaOptions::default();
|
|
let mut writer =
|
|
LzmaWriter::new_use_header(&mut compressed, &options, Some(tar_buf.len() as u64))
|
|
.unwrap();
|
|
writer.write_all(&tar_buf).unwrap();
|
|
writer.finish().unwrap();
|
|
}
|
|
fs::write(&archive_path, compressed).unwrap();
|
|
|
|
let extracted = extract_archive(
|
|
&archive_path,
|
|
&test_spec(),
|
|
&test_source("pkg-1.0"),
|
|
&build_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("hello.txt")).unwrap(),
|
|
"hello-lzma"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_archive_tar_lzip_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let archive_path = tmp.path().join("pkg.tar.lz");
|
|
let build_dir = tmp.path().join("build");
|
|
let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzip");
|
|
|
|
let mut compressed = Vec::new();
|
|
{
|
|
let mut writer = LzipWriter::new(&mut compressed, LzipOptions::default());
|
|
writer.write_all(&tar_buf).unwrap();
|
|
writer.finish().unwrap();
|
|
}
|
|
fs::write(&archive_path, compressed).unwrap();
|
|
|
|
let extracted = extract_archive(
|
|
&archive_path,
|
|
&test_spec(),
|
|
&test_source("pkg-1.0"),
|
|
&build_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("hello.txt")).unwrap(),
|
|
"hello-lzip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_archive_cpio_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let archive_path = tmp.path().join("pkg.cpio");
|
|
let build_dir = tmp.path().join("build");
|
|
|
|
let mut cpio = Vec::new();
|
|
write_cpio_newc_one_file(&mut cpio, "pkg-1.0/hello.txt", b"hello-cpio");
|
|
write_cpio_trailer(&mut cpio);
|
|
fs::write(&archive_path, cpio).unwrap();
|
|
|
|
let extracted = extract_archive(
|
|
&archive_path,
|
|
&test_spec(),
|
|
&test_source("pkg-1.0"),
|
|
&build_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("hello.txt")).unwrap(),
|
|
"hello-cpio"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_file_preserve_metadata_keeps_mtime() {
|
|
let tmp = tempdir().unwrap();
|
|
let src = tmp.path().join("src");
|
|
let dst = tmp.path().join("dst");
|
|
std::fs::write(&src, b"hello").unwrap();
|
|
|
|
let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); // 2000-01-01 UTC
|
|
let ts = FileTime::from_system_time(fixed);
|
|
filetime::set_file_times(&src, ts, ts).unwrap();
|
|
|
|
copy_file_preserve_metadata(&src, &dst).unwrap();
|
|
let src_meta = std::fs::metadata(&src).unwrap();
|
|
let dst_meta = std::fs::metadata(&dst).unwrap();
|
|
assert_eq!(
|
|
FileTime::from_last_modification_time(&dst_meta),
|
|
FileTime::from_last_modification_time(&src_meta)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_entry_fallback_preserves_symlink_when_target_is_missing() {
|
|
let tmp = tempdir().unwrap();
|
|
let src_dir = tmp.path().join("src");
|
|
let dst_dir = tmp.path().join("dst");
|
|
std::fs::create_dir_all(&src_dir).unwrap();
|
|
std::fs::create_dir_all(&dst_dir).unwrap();
|
|
|
|
let src_link = src_dir.join("RELEASE-NOTES");
|
|
std::os::unix::fs::symlink("doc/RelNotes/v1.47.3.txt", &src_link).unwrap();
|
|
|
|
// Simulate target already moved/removed before copying this symlink entry.
|
|
let src_target_dir = src_dir.join("doc");
|
|
std::fs::create_dir_all(src_target_dir.join("RelNotes")).unwrap();
|
|
std::fs::write(src_target_dir.join("RelNotes/v1.47.3.txt"), "notes").unwrap();
|
|
std::fs::remove_dir_all(&src_target_dir).unwrap();
|
|
|
|
let dst_link = dst_dir.join("RELEASE-NOTES");
|
|
let file_type = std::fs::symlink_metadata(&src_link).unwrap().file_type();
|
|
copy_entry_fallback(&src_link, &dst_link, file_type).unwrap();
|
|
|
|
assert!(std::fs::symlink_metadata(&src_link).is_err());
|
|
let dst_meta = std::fs::symlink_metadata(&dst_link).unwrap();
|
|
assert!(dst_meta.file_type().is_symlink());
|
|
assert_eq!(
|
|
std::fs::read_link(&dst_link).unwrap(),
|
|
PathBuf::from("doc/RelNotes/v1.47.3.txt")
|
|
);
|
|
}
|
|
|
|
fn write_cpio_newc_one_file(w: &mut Vec<u8>, name: &str, data: &[u8]) {
|
|
// magic + 13 fields of 8 hex chars each
|
|
fn h8(v: u64) -> String {
|
|
format!("{:08x}", v)
|
|
}
|
|
let namesize = name.len() + 1;
|
|
let filesize = data.len();
|
|
let mut header = Vec::new();
|
|
header.extend_from_slice(b"070701");
|
|
// ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check
|
|
header.extend_from_slice(h8(0).as_bytes()); // ino
|
|
header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms
|
|
header.extend_from_slice(h8(0).as_bytes()); // uid
|
|
header.extend_from_slice(h8(0).as_bytes()); // gid
|
|
header.extend_from_slice(h8(1).as_bytes()); // nlink
|
|
header.extend_from_slice(h8(0).as_bytes()); // mtime
|
|
header.extend_from_slice(h8(filesize as u64).as_bytes());
|
|
header.extend_from_slice(h8(0).as_bytes()); // devmajor
|
|
header.extend_from_slice(h8(0).as_bytes()); // devminor
|
|
header.extend_from_slice(h8(0).as_bytes()); // rdevmajor
|
|
header.extend_from_slice(h8(0).as_bytes()); // rdevminor
|
|
header.extend_from_slice(h8(namesize as u64).as_bytes());
|
|
header.extend_from_slice(h8(0).as_bytes()); // check
|
|
w.extend_from_slice(&header);
|
|
w.extend_from_slice(name.as_bytes());
|
|
w.push(0);
|
|
// pad to 4
|
|
let pad = (4 - ((110 + namesize) % 4)) % 4;
|
|
for _ in 0..pad {
|
|
w.push(0);
|
|
}
|
|
// file data
|
|
w.extend_from_slice(data);
|
|
let dpad = (4 - (filesize % 4)) % 4;
|
|
for _ in 0..dpad {
|
|
w.push(0);
|
|
}
|
|
}
|
|
|
|
fn write_cpio_trailer(w: &mut Vec<u8>) {
|
|
write_cpio_newc_one_file(w, "TRAILER!!!", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_rpm_roundtrip() {
|
|
let tmp = tempdir().unwrap();
|
|
let rpm_path = tmp.path().join("test.rpm");
|
|
let extract_dir = tmp.path().join("out-rpm");
|
|
|
|
// build cpio newc stream with one file
|
|
let mut cpio = Vec::new();
|
|
write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm");
|
|
write_cpio_trailer(&mut cpio);
|
|
|
|
// gzip compress
|
|
let mut gz = Vec::new();
|
|
{
|
|
let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
|
|
enc.write_all(&cpio).unwrap();
|
|
enc.finish().unwrap();
|
|
}
|
|
|
|
// write fake rpm: some header bytes then gz payload
|
|
{
|
|
let mut f = File::create(&rpm_path).unwrap();
|
|
f.write_all(b"RPMHEAD").unwrap();
|
|
f.write_all(&gz).unwrap();
|
|
}
|
|
|
|
fs::create_dir_all(&extract_dir).unwrap();
|
|
extract_rpm(&rpm_path, &extract_dir).unwrap();
|
|
assert!(extract_dir.join("usr/bin/hello-rpm").exists());
|
|
}
|
|
}
|