pull: fix archive extraction and tree assembly
Three archive-side defects broke pulled trees, each surfacing as a later build failure rather than at pull time: - '.tar.bz2'/'.tbz2' origs (busybox) were downloaded but never extracted: the dispatch only knew gz and xz. dpkg-source -b then packed the missing tree as a mass of ignored deletions and the chroot's quilt run failed on every patch. Extract bz2 through bzip2-rs, and match every '.tar.*' of the source package so an unsupported compression fails loudly instead of silently skipping the extraction. - Hard link entries (jq) aborted the whole extraction: resolve their targets against the archive root, where tar guarantees the shared entry was already extracted, with the same confinement checks as regular entries. - The 'package-version/' wrapper was detected from explicit tar directory entries (tree has none, so its sources stayed nested one level too deep) and relocated through a copy that flattened top-level symlinks (htop's 'README -> README.md' became a plain file, which dpkg-source refuses to represent). Recognize the wrapper by the shape of the extraction result - exactly one real directory - and unwrap it by rename, preserving links and timestamps.
This commit is contained in:
+355
-120
@@ -154,14 +154,58 @@ fn copy_file_times(src: &Path, dest: &Path) -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of extracting an archive
|
/// Move the contents of a single top-level directory up into `dir` itself.
|
||||||
struct ExtractedArchive {
|
///
|
||||||
/// Paths of the extracted files
|
/// Upstream tarballs are usually wrapped in a 'package-version/' directory
|
||||||
files: Vec<String>,
|
/// while the tree root is `dir`. The wrapper's entries are moved up one
|
||||||
/// The archive used './'-prefixed entries: its contents were extracted
|
/// level by rename — symlinks and timestamps survive a move, while a copy
|
||||||
/// directly into the destination directory, and there is no
|
/// flattens links and resets times — and the emptied wrapper is removed.
|
||||||
/// 'package-version/' top-level directory to relocate
|
/// Any other extraction shape (several top-level entries, files at the
|
||||||
in_place: bool,
|
/// root, an empty archive) is already in place and is left untouched.
|
||||||
|
fn unwrap_single_dir(dir: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut entries: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
entries.push(entry?.path());
|
||||||
|
}
|
||||||
|
let [wrapper] = entries.as_slice() else {
|
||||||
|
debug!(
|
||||||
|
"{} top-level entries in {}, no wrapper to unwrap",
|
||||||
|
entries.len(),
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// A lone symlink is not a wrapper; unwrapping through it would move
|
||||||
|
// whatever it points to
|
||||||
|
let is_real_dir = std::fs::symlink_metadata(wrapper)
|
||||||
|
.map(|meta| meta.is_dir())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_real_dir {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Unwrapping {} into {}", wrapper.display(), dir.display());
|
||||||
|
for entry in std::fs::read_dir(wrapper)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let target = dir.join(entry.file_name());
|
||||||
|
if !target.exists() {
|
||||||
|
std::fs::rename(entry.path(), &target)?;
|
||||||
|
} else {
|
||||||
|
// The tree root already carries that name (a multi-orig archive
|
||||||
|
// laid files there before): merge over it instead of clobbering
|
||||||
|
if entry.path().is_dir() {
|
||||||
|
std::fs::create_dir_all(&target)?;
|
||||||
|
copy_dir_all(&entry.path(), &target)?;
|
||||||
|
std::fs::remove_dir_all(entry.path())?;
|
||||||
|
} else {
|
||||||
|
std::fs::copy(entry.path(), &target)?;
|
||||||
|
copy_file_times(&entry.path(), &target)?;
|
||||||
|
std::fs::remove_file(entry.path())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::remove_dir(wrapper)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to extract tar archive with progress tracking
|
/// Helper function to extract tar archive with progress tracking
|
||||||
@@ -170,7 +214,7 @@ fn extract_tar_archive<D, F>(
|
|||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: ProgressCallback<'_>,
|
progress: ProgressCallback<'_>,
|
||||||
decoder_factory: F,
|
decoder_factory: F,
|
||||||
) -> Result<ExtractedArchive, Box<dyn Error>>
|
) -> Result<(), Box<dyn Error>>
|
||||||
where
|
where
|
||||||
D: std::io::Read,
|
D: std::io::Read,
|
||||||
F: Fn(File) -> D,
|
F: Fn(File) -> D,
|
||||||
@@ -188,23 +232,15 @@ where
|
|||||||
let decoder = decoder_factory(file);
|
let decoder = decoder_factory(file);
|
||||||
let mut archive = Archive::new(decoder);
|
let mut archive = Archive::new(decoder);
|
||||||
|
|
||||||
let mut extracted_files = Vec::new();
|
|
||||||
let mut in_place = false;
|
|
||||||
|
|
||||||
for entry in archive.entries()? {
|
for entry in archive.entries()? {
|
||||||
let mut entry = entry?;
|
let mut entry = entry?;
|
||||||
let path = entry.path()?.to_path_buf();
|
let path = entry.path()?.to_path_buf();
|
||||||
|
|
||||||
// Archives built with './'-prefixed entries (common in third-party
|
// Archives built with './'-prefixed entries (common in third-party
|
||||||
// repositories) target the destination directory itself, with no
|
// repositories) target the destination directory itself, with no
|
||||||
// 'package-version/' wrapper; skip their root entry, and remember
|
// 'package-version/' wrapper; strip the prefix so entries land there
|
||||||
// the layout for the caller
|
// too, and skip their root entry
|
||||||
let relative = if path.to_string_lossy().starts_with("./") {
|
let relative = path.strip_prefix("./").unwrap_or(&path).to_path_buf();
|
||||||
in_place = true;
|
|
||||||
path.strip_prefix("./").unwrap_or(&path).to_path_buf()
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
};
|
|
||||||
|
|
||||||
if relative.as_os_str().is_empty() {
|
if relative.as_os_str().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -239,9 +275,51 @@ where
|
|||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the file
|
// A hard link shares its content with an earlier entry of the
|
||||||
entry.unpack(&dest_path)?;
|
// archive: tar stores the target path relative to the archive root,
|
||||||
extracted_files.push(dest_path.to_string_lossy().to_string());
|
// where the shared entry has already been extracted. Resolving it
|
||||||
|
// any other way (as `Entry::unpack` does) misses the target and
|
||||||
|
// aborts the whole extraction.
|
||||||
|
if entry.header().entry_type().is_hard_link() {
|
||||||
|
let link_target = entry.link_name()?.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"archive entry '{}' is a hard link without a link target",
|
||||||
|
relative.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Absolute link targets land under the destination root, like
|
||||||
|
// GNU tar treats them; anything else escaping 'dest' is refused
|
||||||
|
let link_relative = link_target.strip_prefix("/").unwrap_or(&link_target);
|
||||||
|
let escapes_dest = link_relative.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::Prefix(_) | Component::RootDir | Component::ParentDir
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if escapes_dest {
|
||||||
|
return Err(format!(
|
||||||
|
"Refusing to extract '{}': hard link target '{}' is \
|
||||||
|
absolute or contains '..' and would escape the \
|
||||||
|
destination directory '{}'",
|
||||||
|
relative.display(),
|
||||||
|
link_relative.display(),
|
||||||
|
dest.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let link_path = dest.join(link_relative);
|
||||||
|
std::fs::hard_link(&link_path, &dest_path).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"hard linking archive entry '{}' to its target '{}' \
|
||||||
|
failed: {e}",
|
||||||
|
relative.display(),
|
||||||
|
link_relative.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
// Extract the entry
|
||||||
|
entry.unpack(&dest_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
current_entry += 1;
|
current_entry += 1;
|
||||||
|
|
||||||
@@ -251,23 +329,22 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ExtractedArchive {
|
Ok(())
|
||||||
files: extracted_files,
|
|
||||||
in_place,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_archive(
|
fn extract_archive(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
progress: ProgressCallback<'_>,
|
progress: ProgressCallback<'_>,
|
||||||
) -> Result<ExtractedArchive, Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let filename = path.file_name().unwrap().to_string_lossy();
|
let filename = path.file_name().unwrap().to_string_lossy();
|
||||||
|
|
||||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||||
extract_tar_archive(path, dest, progress, GzDecoder::new)
|
extract_tar_archive(path, dest, progress, GzDecoder::new)
|
||||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||||
extract_tar_archive(path, dest, progress, XzDecoder::new)
|
extract_tar_archive(path, dest, progress, XzDecoder::new)
|
||||||
|
} else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") {
|
||||||
|
extract_tar_archive(path, dest, progress, bzip2_rs::DecoderReader::new)
|
||||||
} else {
|
} else {
|
||||||
Err(format!("Unsupported archive format: {}", filename).into())
|
Err(format!("Unsupported archive format: {}", filename).into())
|
||||||
}
|
}
|
||||||
@@ -562,66 +639,36 @@ async fn fetch_archive_sources(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Extract all tar archives, merging extracted directories
|
// Extract all tar archives. Every tar of the source package matches
|
||||||
if file.name.ends_with(".tar.gz") || file.name.ends_with(".tar.xz") {
|
// (orig, debian tar, native tar, extra components); a tarball in an
|
||||||
|
// unsupported compression fails loudly in extract_archive rather
|
||||||
|
// than being left unextracted — a silently missing tree only
|
||||||
|
// surfaces as warnings from dpkg-source and broken patches later.
|
||||||
|
// Detached signatures ('.tar.gz.asc' and friends) are not tars.
|
||||||
|
let is_tar = !file.name.ends_with(".asc")
|
||||||
|
&& (file.name.contains(".tar.")
|
||||||
|
|| file.name.ends_with(".tgz")
|
||||||
|
|| file.name.ends_with(".tbz2"));
|
||||||
|
if is_tar {
|
||||||
let path = package_dir.join(&file.name);
|
let path = package_dir.join(&file.name);
|
||||||
let extract_dir = package_dir.join(&info.stanza.package);
|
let extract_dir = package_dir.join(&info.stanza.package);
|
||||||
|
|
||||||
let extracted = extract_archive(&path, &extract_dir, progress)?;
|
extract_archive(&path, &extract_dir, progress)?;
|
||||||
|
|
||||||
// Special case: the debian tar does only contain 'debian'
|
// Special case: the debian tar does only contain 'debian'
|
||||||
if file.name.contains("debian.tar.") {
|
if file.name.contains("debian.tar.") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archives with './'-prefixed entries are already laid out
|
// The tree root is the extraction directory itself: an archive
|
||||||
// directly in the package directory; only 'package-version/'
|
// wrapped in a single 'package-version/' directory (the usual
|
||||||
// style ones need their contents relocated
|
// upstream layout) has that wrapper unwrapped, anything else is
|
||||||
if !extracted.in_place {
|
// already laid out in place. The wrapper is recognized by the
|
||||||
// List root directories extracted and use the first one as the source directory
|
// shape of the extraction result rather than by tar directory
|
||||||
debug!("Root directories extracted:");
|
// entries: tarballs are not required to carry entries for their
|
||||||
let mut source_dir: Option<PathBuf> = None;
|
// directories, and a missing entry used to leave the sources
|
||||||
for file in &extracted.files {
|
// nested one level too deep.
|
||||||
let path = Path::new(file);
|
unwrap_single_dir(&extract_dir)?;
|
||||||
// Check if this is a directory and is at the archive root level
|
|
||||||
// (i.e., the path relative to extract_dir has no parent components)
|
|
||||||
if let Ok(relative_path) = path.strip_prefix(&extract_dir)
|
|
||||||
&& relative_path.components().count() == 1
|
|
||||||
&& path.is_dir()
|
|
||||||
{
|
|
||||||
debug!("- {}", relative_path.file_name().unwrap().to_string_lossy());
|
|
||||||
// Use the first directory found as the source
|
|
||||||
if source_dir.is_none() {
|
|
||||||
source_dir = Some(path.to_path_buf());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the extracted directory as the source, assuming there is only one
|
|
||||||
if let Some(src_dir) = source_dir {
|
|
||||||
let target_dir = package_dir.join(&info.stanza.package);
|
|
||||||
|
|
||||||
if target_dir.exists() {
|
|
||||||
// Target exists, we need to merge contents
|
|
||||||
for sub_entry in std::fs::read_dir(&src_dir)? {
|
|
||||||
let sub_entry = sub_entry?;
|
|
||||||
let sub_path = sub_entry.path();
|
|
||||||
let target_path = target_dir.join(sub_entry.file_name());
|
|
||||||
if sub_path.is_dir() {
|
|
||||||
std::fs::create_dir_all(&target_path)?;
|
|
||||||
// Recursively copy directory contents
|
|
||||||
copy_dir_all(&sub_path, &target_path)?;
|
|
||||||
} else {
|
|
||||||
std::fs::copy(&sub_path, &target_path)?;
|
|
||||||
copy_file_times(&sub_path, &target_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::fs::remove_dir_all(&src_dir)?;
|
|
||||||
} else {
|
|
||||||
std::fs::rename(&src_dir, &target_dir)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and apply .diff.gz if present (old packages)
|
// Extract and apply .diff.gz if present (old packages)
|
||||||
@@ -946,39 +993,54 @@ mod tests {
|
|||||||
fn build_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
fn build_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (name, data) in entries {
|
for (name, data) in entries {
|
||||||
let mut block = [0u8; 512];
|
push_tar_entry(&mut out, name, b'0', data, None);
|
||||||
block[..name.len()].copy_from_slice(name.as_bytes());
|
|
||||||
block[100..108].copy_from_slice(b"0000644\0");
|
|
||||||
block[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
|
|
||||||
block[136..148].copy_from_slice(b"00000000000\0");
|
|
||||||
block[156] = b'0'; // regular file
|
|
||||||
block[257..263].copy_from_slice(b"ustar\0");
|
|
||||||
block[263..265].copy_from_slice(b"00");
|
|
||||||
|
|
||||||
// Checksum: sum of the header bytes with the checksum field
|
|
||||||
// (bytes 148..156) taken as spaces
|
|
||||||
let mut checksum: u32 = 0;
|
|
||||||
for (i, byte) in block.iter().enumerate() {
|
|
||||||
checksum += if (148..156).contains(&i) {
|
|
||||||
u32::from(b' ')
|
|
||||||
} else {
|
|
||||||
u32::from(*byte)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
block[148..154].copy_from_slice(format!("{checksum:06o}").as_bytes());
|
|
||||||
block[154] = 0;
|
|
||||||
block[155] = b' ';
|
|
||||||
|
|
||||||
out.extend_from_slice(&block);
|
|
||||||
out.extend_from_slice(data);
|
|
||||||
let padding = (512 - (data.len() % 512)) % 512;
|
|
||||||
out.extend_from_slice(&vec![0u8; padding]);
|
|
||||||
}
|
}
|
||||||
// End-of-archive marker: two zero-filled blocks
|
|
||||||
out.extend_from_slice(&[0u8; 1024]);
|
out.extend_from_slice(&[0u8; 1024]);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append one raw ustar entry: `typeflag` selects the type ('0' regular
|
||||||
|
/// file, '1' hard link, '2' symlink) and `linkname` is the stored link
|
||||||
|
/// target for the link types.
|
||||||
|
fn push_tar_entry(
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
name: &str,
|
||||||
|
typeflag: u8,
|
||||||
|
data: &[u8],
|
||||||
|
linkname: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut block = [0u8; 512];
|
||||||
|
block[..name.len()].copy_from_slice(name.as_bytes());
|
||||||
|
block[100..108].copy_from_slice(b"0000644\0");
|
||||||
|
block[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
|
||||||
|
block[136..148].copy_from_slice(b"00000000000\0");
|
||||||
|
block[156] = typeflag;
|
||||||
|
if let Some(linkname) = linkname {
|
||||||
|
block[157..157 + linkname.len()].copy_from_slice(linkname.as_bytes());
|
||||||
|
}
|
||||||
|
block[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
block[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
// Checksum: sum of the header bytes with the checksum field
|
||||||
|
// (bytes 148..156) taken as spaces
|
||||||
|
let mut checksum: u32 = 0;
|
||||||
|
for (i, byte) in block.iter().enumerate() {
|
||||||
|
checksum += if (148..156).contains(&i) {
|
||||||
|
u32::from(b' ')
|
||||||
|
} else {
|
||||||
|
u32::from(*byte)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
block[148..154].copy_from_slice(format!("{checksum:06o}").as_bytes());
|
||||||
|
block[154] = 0;
|
||||||
|
block[155] = b' ';
|
||||||
|
|
||||||
|
out.extend_from_slice(&block);
|
||||||
|
out.extend_from_slice(data);
|
||||||
|
let padding = (512 - (data.len() % 512)) % 512;
|
||||||
|
out.extend_from_slice(&vec![0u8; padding]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_tar_rejects_parent_dir_traversal() {
|
fn test_extract_tar_rejects_parent_dir_traversal() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1056,7 +1118,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let dest = temp_dir.path().join("dest");
|
let dest = temp_dir.path().join("dest");
|
||||||
let extracted = extract_tar_archive(&tar_path, &dest, None, |f| f).unwrap();
|
extract_tar_archive(&tar_path, &dest, None, |f| f).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(dest.join("hello-1.0/file.txt")).unwrap(),
|
std::fs::read_to_string(dest.join("hello-1.0/file.txt")).unwrap(),
|
||||||
@@ -1066,19 +1128,192 @@ mod tests {
|
|||||||
std::fs::read_to_string(dest.join("debian/rules")).unwrap(),
|
std::fs::read_to_string(dest.join("debian/rules")).unwrap(),
|
||||||
"#!/m"
|
"#!/m"
|
||||||
);
|
);
|
||||||
assert!(extracted.in_place);
|
}
|
||||||
assert!(
|
|
||||||
extracted.files.contains(
|
#[test]
|
||||||
&dest
|
fn test_extract_tar_hardlink_resolved_against_archive_root() {
|
||||||
.join("hello-1.0/file.txt")
|
// Regression: a hard link entry (e.g. the jq orig tarball) aborted
|
||||||
.to_string_lossy()
|
// the whole extraction when its target was not found where the
|
||||||
.to_string()
|
// extractor resolved it; tar stores link targets relative to the
|
||||||
)
|
// archive root
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("hardlink.orig.tar");
|
||||||
|
let mut tar_bytes = Vec::new();
|
||||||
|
push_tar_entry(&mut tar_bytes, "pkg-1.0/manual.txt", b'0', b"content", None);
|
||||||
|
push_tar_entry(
|
||||||
|
&mut tar_bytes,
|
||||||
|
"pkg-1.0/docs/v1/manual.txt",
|
||||||
|
b'1',
|
||||||
|
b"",
|
||||||
|
Some("pkg-1.0/manual.txt"),
|
||||||
);
|
);
|
||||||
assert!(
|
tar_bytes.extend_from_slice(&[0u8; 1024]);
|
||||||
extracted
|
std::fs::write(&tar_path, tar_bytes).unwrap();
|
||||||
.files
|
|
||||||
.contains(&dest.join("debian/rules").to_string_lossy().to_string())
|
let dest = temp_dir.path().join("dest");
|
||||||
|
extract_tar_archive(&tar_path, &dest, None, |f| f).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dest.join("pkg-1.0/docs/v1/manual.txt")).unwrap(),
|
||||||
|
"content"
|
||||||
|
);
|
||||||
|
// A real hard link shares the inode with its target
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
assert_eq!(
|
||||||
|
dest.join("pkg-1.0/manual.txt").metadata().unwrap().ino(),
|
||||||
|
dest.join("pkg-1.0/docs/v1/manual.txt")
|
||||||
|
.metadata()
|
||||||
|
.unwrap()
|
||||||
|
.ino()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tar_rejects_hardlink_escape() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("malicious.orig.tar");
|
||||||
|
let mut tar_bytes = Vec::new();
|
||||||
|
push_tar_entry(&mut tar_bytes, "good.txt", b'0', b"ok", None);
|
||||||
|
push_tar_entry(&mut tar_bytes, "evil.txt", b'1', b"", Some("../evil.txt"));
|
||||||
|
tar_bytes.extend_from_slice(&[0u8; 1024]);
|
||||||
|
std::fs::write(&tar_path, tar_bytes).unwrap();
|
||||||
|
|
||||||
|
let dest = temp_dir.path().join("dest");
|
||||||
|
let result = extract_tar_archive(&tar_path, &dest, None, |f| f);
|
||||||
|
|
||||||
|
let err = match result {
|
||||||
|
Ok(_) => panic!("extraction of an escaping hard link should fail"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("../evil.txt"),
|
||||||
|
"error should name the offending link target, got: {err}"
|
||||||
|
);
|
||||||
|
assert!(!temp_dir.path().join("evil.txt").exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dest.join("good.txt")).unwrap(),
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unwrap_single_dir_without_dir_entries() {
|
||||||
|
// Regression: tarballs carrying no explicit entries for their
|
||||||
|
// directories (e.g. the 'tree' orig) used to skip the relocation
|
||||||
|
// entirely, leaving the sources nested one level too deep and
|
||||||
|
// dpkg-source rejecting the tree as 'unexpected upstream changes'
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("wrapped.orig.tar");
|
||||||
|
std::fs::write(
|
||||||
|
&tar_path,
|
||||||
|
build_tar(&[
|
||||||
|
("tree-2.3.2/Makefile", b"all:"),
|
||||||
|
("tree-2.3.2/doc/tree.1", b"th"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tree = temp_dir.path().join("tree");
|
||||||
|
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
|
||||||
|
unwrap_single_dir(&tree).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("Makefile")).unwrap(),
|
||||||
|
"all:"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("doc/tree.1")).unwrap(),
|
||||||
|
"th"
|
||||||
|
);
|
||||||
|
assert!(!tree.join("tree-2.3.2").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unwrap_single_dir_preserves_symlinks() {
|
||||||
|
// Regression: the copy-based relocation used to flatten top-level
|
||||||
|
// symlinks into plain files (dpkg-source: 'cannot represent change
|
||||||
|
// to ...: old version is symlink')
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("wrapped.orig.tar");
|
||||||
|
let mut tar_bytes = Vec::new();
|
||||||
|
push_tar_entry(&mut tar_bytes, "pkg-1.0/README.md", b'0', b"readme", None);
|
||||||
|
push_tar_entry(
|
||||||
|
&mut tar_bytes,
|
||||||
|
"pkg-1.0/README",
|
||||||
|
b'2',
|
||||||
|
b"",
|
||||||
|
Some("README.md"),
|
||||||
|
);
|
||||||
|
tar_bytes.extend_from_slice(&[0u8; 1024]);
|
||||||
|
std::fs::write(&tar_path, tar_bytes).unwrap();
|
||||||
|
|
||||||
|
let tree = temp_dir.path().join("tree");
|
||||||
|
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
|
||||||
|
unwrap_single_dir(&tree).unwrap();
|
||||||
|
|
||||||
|
let meta = std::fs::symlink_metadata(tree.join("README")).unwrap();
|
||||||
|
assert!(meta.file_type().is_symlink());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_link(tree.join("README")).unwrap(),
|
||||||
|
Path::new("README.md")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("README")).unwrap(),
|
||||||
|
"readme"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unwrap_single_dir_leaves_flat_layout() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("flat.orig.tar");
|
||||||
|
std::fs::write(
|
||||||
|
&tar_path,
|
||||||
|
build_tar(&[("Makefile", b"all:"), ("debian/rules", b"#!/m")]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tree = temp_dir.path().join("tree");
|
||||||
|
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
|
||||||
|
unwrap_single_dir(&tree).unwrap();
|
||||||
|
|
||||||
|
assert!(tree.join("Makefile").exists());
|
||||||
|
assert!(tree.join("debian/rules").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal ustar archive of `pkg-1.0/{Makefile,doc/readme}`,
|
||||||
|
/// bz2-compressed.
|
||||||
|
const BZ2_TAR_WRAPPED: &[u8] = &[
|
||||||
|
66, 90, 104, 57, 49, 65, 89, 38, 83, 89, 88, 108, 110, 100, 0, 0, 154, 255, 128, 201, 128,
|
||||||
|
0, 16, 64, 3, 231, 144, 0, 3, 64, 0, 111, 174, 222, 0, 8, 40, 32, 0, 116, 18, 148, 218,
|
||||||
|
147, 200, 67, 33, 166, 100, 70, 65, 250, 83, 212, 18, 145, 1, 166, 128, 0, 0, 208, 62, 196,
|
||||||
|
104, 44, 169, 64, 3, 160, 68, 65, 10, 40, 112, 177, 128, 161, 163, 8, 68, 16, 80, 10, 79,
|
||||||
|
36, 5, 53, 192, 112, 39, 81, 227, 129, 9, 73, 68, 32, 93, 136, 243, 37, 49, 35, 19, 133,
|
||||||
|
118, 14, 245, 37, 8, 233, 250, 99, 27, 194, 182, 183, 86, 186, 163, 237, 147, 31, 53, 154,
|
||||||
|
53, 20, 231, 189, 6, 40, 100, 34, 7, 241, 119, 36, 83, 133, 9, 5, 134, 198, 230, 64,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tar_bz2() {
|
||||||
|
// Regression: '.tar.bz2' origs (e.g. busybox) were downloaded but
|
||||||
|
// never extracted — the dispatch only knew gz and xz — leaving a
|
||||||
|
// tree that dpkg-source packed as a mass of ignored deletions
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let tar_path = temp_dir.path().join("wrapped.orig.tar.bz2");
|
||||||
|
std::fs::write(&tar_path, BZ2_TAR_WRAPPED).unwrap();
|
||||||
|
|
||||||
|
let tree = temp_dir.path().join("tree");
|
||||||
|
extract_tar_archive(&tar_path, &tree, None, bzip2_rs::DecoderReader::new).unwrap();
|
||||||
|
unwrap_single_dir(&tree).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("Makefile")).unwrap(),
|
||||||
|
"all:"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tree.join("doc/readme")).unwrap(),
|
||||||
|
"docs"
|
||||||
|
);
|
||||||
|
assert!(!tree.join("pkg-1.0").exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user