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:
2026-10-01 22:23:43 +02:00
parent 2d57b1f0bc
commit 913ef2027a
+355 -120
View File
@@ -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());
}
} }