pull: unwrap multi-orig components into their addon directory

dpkg-source assembles a multi-orig source by unpacking the main orig
first and the extra components over it, each component's content at
'<root>/<addon>' ('pigeonhole' for 'pkg_1.0.orig-pigeonhole.tar.gz'),
so component files win where they overlap the main tree: process the
archives in that order, and unwrap each archive's wrapper into where
dpkg-source expects it - the root for the main orig, '<root>/<addon>'
for a component, replacing any pre-existing addon directory.

Recognizing the wrapper per archive (the one first component every
entry of THIS archive shares) rather than from the shape of the tree
root keeps every component of a multi-orig package unwrapping after
the first one filled the root: dovecot's pigeonhole sources used to
be left nested one level too deep, and dpkg-source -b then refused
the tree over unexpected upstream changes.
This commit is contained in:
2026-10-02 01:18:22 +02:00
parent b143fa661d
commit 3227c0abb9
+160 -49
View File
@@ -154,36 +154,49 @@ fn copy_file_times(src: &Path, dest: &Path) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
/// Move the contents of a single top-level directory up into `dir` itself. /// The addon name of an extra upstream component tarball ('pigeonhole' for
/// 'pkg_1.0.orig-pigeonhole.tar.gz'): dpkg-source unpacks it into
/// '<root>/<addon>'. None for the main orig and every non-component file.
fn component_addon(name: &str) -> Option<&str> {
let rest = name.split_once(".orig-")?.1;
let addon = rest.split(".tar.").next()?;
if addon.is_empty() {
None
} else {
Some(addon)
}
}
/// Move the contents of an archive's wrapper directory into `dir`, or into
/// `dir/<addon>` for a multi-orig component, replacing any pre-existing
/// addon directory.
fn unwrap_into(
dir: &Path,
wrapper: &Path,
addon: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let target = match addon {
Some(addon) => {
let target = dir.join(addon);
if target.is_dir() {
std::fs::remove_dir_all(&target)?;
}
target
}
None => dir.to_path_buf(),
};
unwrap_wrapper(&target, wrapper)
}
/// Move the contents of an archive's wrapper directory up into `dir` itself.
/// ///
/// Upstream tarballs are usually wrapped in a 'package-version/' directory /// Upstream tarballs are usually wrapped in a 'package-version/' directory
/// while the tree root is `dir`. The wrapper's entries are moved up one /// while the tree root is `dir`. The wrapper's entries are moved up one
/// level by rename — symlinks and timestamps survive a move, while a copy /// level by rename — symlinks and timestamps survive a move, while a copy
/// flattens links and resets times — and the emptied wrapper is removed. /// flattens links and resets times — and the emptied wrapper is removed.
/// Any other extraction shape (several top-level entries, files at the /// A name already present at the root (a multi-orig component laid files
/// root, an empty archive) is already in place and is left untouched. /// there before) is merged over rather than clobbered.
fn unwrap_single_dir(dir: &Path) -> Result<(), Box<dyn Error>> { fn unwrap_wrapper(dir: &Path, wrapper: &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()); debug!("Unwrapping {} into {}", wrapper.display(), dir.display());
for entry in std::fs::read_dir(wrapper)? { for entry in std::fs::read_dir(wrapper)? {
let entry = entry?; let entry = entry?;
@@ -208,13 +221,22 @@ fn unwrap_single_dir(dir: &Path) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
/// Result of extracting an archive
struct ExtractedArchive {
/// The single first path component every entry of the archive shares:
/// its 'package-version/' wrapper, to unwrap by the caller. `None` when
/// the archive is already laid out in place (several first components,
/// root-level files, an empty archive).
wrapper: Option<PathBuf>,
}
/// Helper function to extract tar archive with progress tracking /// Helper function to extract tar archive with progress tracking
fn extract_tar_archive<D, F>( fn extract_tar_archive<D, F>(
file_path: &Path, file_path: &Path,
dest: &Path, dest: &Path,
progress: ProgressCallback<'_>, progress: ProgressCallback<'_>,
decoder_factory: F, decoder_factory: F,
) -> Result<(), Box<dyn Error>> ) -> Result<ExtractedArchive, Box<dyn Error>>
where where
D: std::io::Read, D: std::io::Read,
F: Fn(File) -> D, F: Fn(File) -> D,
@@ -232,6 +254,13 @@ where
let decoder = decoder_factory(file); let decoder = decoder_factory(file);
let mut archive = Archive::new(decoder); let mut archive = Archive::new(decoder);
// The archive's wrapper: the one first path component every entry
// shares. Tracked from the entries themselves — tarballs are not
// required to carry entries for their directories, and a missing entry
// used to leave the sources nested one level too deep.
let mut root: Option<std::ffi::OsString> = None;
let mut multiple_roots = 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();
@@ -246,6 +275,15 @@ where
continue; continue;
} }
let first = relative.components().next();
if let Some(Component::Normal(name)) = first {
match root.take() {
None => root = Some(name.to_os_string()),
Some(shared) if shared.as_os_str() == name => root = Some(shared),
Some(_) => multiple_roots = true,
}
}
// Security: `Entry::unpack` performs no path sanitization, so extract // Security: `Entry::unpack` performs no path sanitization, so extract
// the entry only if its path is confined to the destination directory. // the entry only if its path is confined to the destination directory.
// An absolute path or a path containing '..' would let a malicious (or // An absolute path or a path containing '..' would let a malicious (or
@@ -329,14 +367,19 @@ where
} }
} }
Ok(()) let wrapper = if multiple_roots {
None
} else {
root.map(|r| dest.join(r))
};
Ok(ExtractedArchive { wrapper })
} }
fn extract_archive( fn extract_archive(
path: &Path, path: &Path,
dest: &Path, dest: &Path,
progress: ProgressCallback<'_>, progress: ProgressCallback<'_>,
) -> Result<(), Box<dyn Error>> { ) -> Result<ExtractedArchive, 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") {
@@ -628,7 +671,25 @@ async fn fetch_archive_sources(
std::fs::create_dir_all(package_dir)?; std::fs::create_dir_all(package_dir)?;
for file in &info.stanza.files { // dpkg-source assembles a multi-orig source by unpacking the main orig
// first and the extra components over it, so component files win where
// they overlap the main tree. Process the archives in the same order,
// or the merged tree diverges from what the source package contains.
fn archive_order(name: &str) -> usize {
if name.contains(".orig-") {
1
} else if name.contains(".orig.tar.") {
0
} else if name.contains("debian.tar.") {
2
} else {
3
}
}
let mut files: Vec<&crate::package_info::FileEntry> = info.stanza.files.iter().collect();
files.sort_by_key(|file| archive_order(&file.name));
for file in &files {
let url = format!("{}/{}", info.archive_url, file.name); let url = format!("{}/{}", info.archive_url, file.name);
download_file_checksum( download_file_checksum(
&url, &url,
@@ -653,7 +714,7 @@ async fn fetch_archive_sources(
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);
extract_archive(&path, &extract_dir, progress)?; let extracted = 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.") {
@@ -661,14 +722,20 @@ async fn fetch_archive_sources(
} }
// The tree root is the extraction directory itself: an archive // The tree root is the extraction directory itself: an archive
// wrapped in a single 'package-version/' directory (the usual // wrapped in a 'package-version/' directory (the usual upstream
// upstream layout) has that wrapper unwrapped, anything else is // layout, and multi-orig components carry their own) has that
// already laid out in place. The wrapper is recognized by the // wrapper unwrapped into where dpkg-source expects its content —
// shape of the extraction result rather than by tar directory // the root for the main orig, '<root>/<addon>' for an extra
// entries: tarballs are not required to carry entries for their // component ('.orig-<addon>.tar'), replacing any pre-existing
// directories, and a missing entry used to leave the sources // addon directory (dpkg-source's own multi-orig assembly). The
// nested one level too deep. // wrapper is the one first path component every entry of THIS
unwrap_single_dir(&extract_dir)?; // archive shares — recognizing it per archive rather than from
// the shape of the tree root keeps every component of a
// multi-orig package unwrapping after the first one filled the
// root.
if let Some(wrapper) = extracted.wrapper {
unwrap_into(&extract_dir, &wrapper, component_addon(&file.name))?;
}
} }
// Extract and apply .diff.gz if present (old packages) // Extract and apply .diff.gz if present (old packages)
@@ -1197,7 +1264,7 @@ mod tests {
} }
#[test] #[test]
fn test_unwrap_single_dir_without_dir_entries() { fn test_unwrap_wrapper_without_dir_entries() {
// Regression: tarballs carrying no explicit entries for their // Regression: tarballs carrying no explicit entries for their
// directories (e.g. the 'tree' orig) used to skip the relocation // directories (e.g. the 'tree' orig) used to skip the relocation
// entirely, leaving the sources nested one level too deep and // entirely, leaving the sources nested one level too deep and
@@ -1214,8 +1281,8 @@ mod tests {
.unwrap(); .unwrap();
let tree = temp_dir.path().join("tree"); let tree = temp_dir.path().join("tree");
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); let extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
unwrap_single_dir(&tree).unwrap(); unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap();
assert_eq!( assert_eq!(
std::fs::read_to_string(tree.join("Makefile")).unwrap(), std::fs::read_to_string(tree.join("Makefile")).unwrap(),
@@ -1229,7 +1296,7 @@ mod tests {
} }
#[test] #[test]
fn test_unwrap_single_dir_preserves_symlinks() { fn test_unwrap_wrapper_preserves_symlinks() {
// Regression: the copy-based relocation used to flatten top-level // Regression: the copy-based relocation used to flatten top-level
// symlinks into plain files (dpkg-source: 'cannot represent change // symlinks into plain files (dpkg-source: 'cannot represent change
// to ...: old version is symlink') // to ...: old version is symlink')
@@ -1248,8 +1315,8 @@ mod tests {
std::fs::write(&tar_path, tar_bytes).unwrap(); std::fs::write(&tar_path, tar_bytes).unwrap();
let tree = temp_dir.path().join("tree"); let tree = temp_dir.path().join("tree");
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); let extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
unwrap_single_dir(&tree).unwrap(); unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap();
let meta = std::fs::symlink_metadata(tree.join("README")).unwrap(); let meta = std::fs::symlink_metadata(tree.join("README")).unwrap();
assert!(meta.file_type().is_symlink()); assert!(meta.file_type().is_symlink());
@@ -1264,7 +1331,7 @@ mod tests {
} }
#[test] #[test]
fn test_unwrap_single_dir_leaves_flat_layout() { fn test_unwrap_wrapper_leaves_flat_layout() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let tar_path = temp_dir.path().join("flat.orig.tar"); let tar_path = temp_dir.path().join("flat.orig.tar");
std::fs::write( std::fs::write(
@@ -1274,13 +1341,56 @@ mod tests {
.unwrap(); .unwrap();
let tree = temp_dir.path().join("tree"); let tree = temp_dir.path().join("tree");
extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); let extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap();
unwrap_single_dir(&tree).unwrap(); assert!(extracted.wrapper.is_none());
assert!(tree.join("Makefile").exists()); assert!(tree.join("Makefile").exists());
assert!(tree.join("debian/rules").exists()); assert!(tree.join("debian/rules").exists());
} }
#[test]
fn test_unwrap_wrapper_of_second_multi_orig_component() {
// Regression: multi-orig packages (e.g. dovecot) extract a second
// component into a tree root the first component already filled;
// recognizing the wrapper from the shape of the root skipped the
// second component's unwrap and left its sources nested one level
// too deep
let temp_dir = tempfile::tempdir().unwrap();
let tree = temp_dir.path().join("tree");
let main = temp_dir.path().join("main.orig.tar");
std::fs::write(&main, build_tar(&[("dovecot-2.4.5/README", b"main")])).unwrap();
let extracted = extract_tar_archive(&main, &tree, None, |f| f).unwrap();
unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap();
let component = temp_dir.path().join("component.orig.tar");
std::fs::write(
&component,
build_tar(&[
("dovecot-pigeonhole-2.4.5/update-version.sh", b"sh"),
("dovecot-pigeonhole-2.4.5/pigeonhole/x", b"ph"),
]),
)
.unwrap();
let extracted = extract_tar_archive(&component, &tree, None, |f| f).unwrap();
unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap();
assert_eq!(
std::fs::read_to_string(tree.join("README")).unwrap(),
"main"
);
assert_eq!(
std::fs::read_to_string(tree.join("update-version.sh")).unwrap(),
"sh"
);
assert_eq!(
std::fs::read_to_string(tree.join("pigeonhole/x")).unwrap(),
"ph"
);
assert!(!tree.join("dovecot-2.4.5").exists());
assert!(!tree.join("dovecot-pigeonhole-2.4.5").exists());
}
/// A minimal ustar archive of `pkg-1.0/{Makefile,doc/readme}`, /// A minimal ustar archive of `pkg-1.0/{Makefile,doc/readme}`,
/// bz2-compressed. /// bz2-compressed.
const BZ2_TAR_WRAPPED: &[u8] = &[ const BZ2_TAR_WRAPPED: &[u8] = &[
@@ -1303,8 +1413,9 @@ mod tests {
std::fs::write(&tar_path, BZ2_TAR_WRAPPED).unwrap(); std::fs::write(&tar_path, BZ2_TAR_WRAPPED).unwrap();
let tree = temp_dir.path().join("tree"); let tree = temp_dir.path().join("tree");
extract_tar_archive(&tar_path, &tree, None, bzip2_rs::DecoderReader::new).unwrap(); let extracted =
unwrap_single_dir(&tree).unwrap(); extract_tar_archive(&tar_path, &tree, None, bzip2_rs::DecoderReader::new).unwrap();
unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap();
assert_eq!( assert_eq!(
std::fs::read_to_string(tree.join("Makefile")).unwrap(), std::fs::read_to_string(tree.join("Makefile")).unwrap(),