diff --git a/src/pull.rs b/src/pull.rs index e4b7104..32c02cf 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -154,36 +154,49 @@ fn copy_file_times(src: &Path, dest: &Path) -> Result<(), Box> { 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 +/// '/'. 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/` for a multi-orig component, replacing any pre-existing +/// addon directory. +fn unwrap_into( + dir: &Path, + wrapper: &Path, + addon: Option<&str>, +) -> Result<(), Box> { + 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 /// 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 /// flattens links and resets times — and the emptied wrapper is removed. -/// Any other extraction shape (several top-level entries, files at the -/// root, an empty archive) is already in place and is left untouched. -fn unwrap_single_dir(dir: &Path) -> Result<(), Box> { - let mut entries: Vec = 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(()); - } - +/// A name already present at the root (a multi-orig component laid files +/// there before) is merged over rather than clobbered. +fn unwrap_wrapper(dir: &Path, wrapper: &Path) -> Result<(), Box> { debug!("Unwrapping {} into {}", wrapper.display(), dir.display()); for entry in std::fs::read_dir(wrapper)? { let entry = entry?; @@ -208,13 +221,22 @@ fn unwrap_single_dir(dir: &Path) -> Result<(), Box> { 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, +} + /// Helper function to extract tar archive with progress tracking fn extract_tar_archive( file_path: &Path, dest: &Path, progress: ProgressCallback<'_>, decoder_factory: F, -) -> Result<(), Box> +) -> Result> where D: std::io::Read, F: Fn(File) -> D, @@ -232,6 +254,13 @@ where let decoder = decoder_factory(file); 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 = None; + let mut multiple_roots = false; + for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?.to_path_buf(); @@ -246,6 +275,15 @@ where 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 // the entry only if its path is confined to the destination directory. // 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( path: &Path, dest: &Path, progress: ProgressCallback<'_>, -) -> Result<(), Box> { +) -> Result> { let filename = path.file_name().unwrap().to_string_lossy(); 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)?; - 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); download_file_checksum( &url, @@ -653,7 +714,7 @@ async fn fetch_archive_sources( let path = package_dir.join(&file.name); 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' 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 - // wrapped in a single 'package-version/' directory (the usual - // upstream layout) has that wrapper unwrapped, anything else is - // already laid out in place. The wrapper is recognized by the - // shape of the extraction result rather than by tar directory - // entries: tarballs are not required to carry entries for their - // directories, and a missing entry used to leave the sources - // nested one level too deep. - unwrap_single_dir(&extract_dir)?; + // wrapped in a 'package-version/' directory (the usual upstream + // layout, and multi-orig components carry their own) has that + // wrapper unwrapped into where dpkg-source expects its content — + // the root for the main orig, '/' for an extra + // component ('.orig-.tar'), replacing any pre-existing + // addon directory (dpkg-source's own multi-orig assembly). The + // wrapper is the one first path component every entry of THIS + // 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) @@ -1197,7 +1264,7 @@ mod tests { } #[test] - fn test_unwrap_single_dir_without_dir_entries() { + fn test_unwrap_wrapper_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 @@ -1214,8 +1281,8 @@ mod tests { .unwrap(); let tree = temp_dir.path().join("tree"); - extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); - unwrap_single_dir(&tree).unwrap(); + let extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); + unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap(); assert_eq!( std::fs::read_to_string(tree.join("Makefile")).unwrap(), @@ -1229,7 +1296,7 @@ mod tests { } #[test] - fn test_unwrap_single_dir_preserves_symlinks() { + fn test_unwrap_wrapper_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') @@ -1248,8 +1315,8 @@ mod tests { 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 extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); + unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap(); let meta = std::fs::symlink_metadata(tree.join("README")).unwrap(); assert!(meta.file_type().is_symlink()); @@ -1264,7 +1331,7 @@ mod tests { } #[test] - fn test_unwrap_single_dir_leaves_flat_layout() { + fn test_unwrap_wrapper_leaves_flat_layout() { let temp_dir = tempfile::tempdir().unwrap(); let tar_path = temp_dir.path().join("flat.orig.tar"); std::fs::write( @@ -1274,13 +1341,56 @@ mod tests { .unwrap(); let tree = temp_dir.path().join("tree"); - extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); - unwrap_single_dir(&tree).unwrap(); + let extracted = extract_tar_archive(&tar_path, &tree, None, |f| f).unwrap(); + assert!(extracted.wrapper.is_none()); assert!(tree.join("Makefile").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}`, /// bz2-compressed. const BZ2_TAR_WRAPPED: &[u8] = &[ @@ -1303,8 +1413,9 @@ mod tests { 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(); + let extracted = + extract_tar_archive(&tar_path, &tree, None, bzip2_rs::DecoderReader::new).unwrap(); + unwrap_wrapper(&tree, &extracted.wrapper.unwrap()).unwrap(); assert_eq!( std::fs::read_to_string(tree.join("Makefile")).unwrap(),