diff --git a/src/pull.rs b/src/pull.rs index 7386ec2..e4b7104 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -154,14 +154,58 @@ fn copy_file_times(src: &Path, dest: &Path) -> Result<(), Box> { Ok(()) } -/// Result of extracting an archive -struct ExtractedArchive { - /// Paths of the extracted files - files: Vec, - /// The archive used './'-prefixed entries: its contents were extracted - /// directly into the destination directory, and there is no - /// 'package-version/' top-level directory to relocate - in_place: bool, +/// Move the contents of a single top-level 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(()); + } + + 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 @@ -170,7 +214,7 @@ fn extract_tar_archive( dest: &Path, progress: ProgressCallback<'_>, decoder_factory: F, -) -> Result> +) -> Result<(), Box> where D: std::io::Read, F: Fn(File) -> D, @@ -188,23 +232,15 @@ where let decoder = decoder_factory(file); let mut archive = Archive::new(decoder); - let mut extracted_files = Vec::new(); - let mut in_place = false; - for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?.to_path_buf(); // Archives built with './'-prefixed entries (common in third-party // repositories) target the destination directory itself, with no - // 'package-version/' wrapper; skip their root entry, and remember - // the layout for the caller - let relative = if path.to_string_lossy().starts_with("./") { - in_place = true; - path.strip_prefix("./").unwrap_or(&path).to_path_buf() - } else { - path - }; + // 'package-version/' wrapper; strip the prefix so entries land there + // too, and skip their root entry + let relative = path.strip_prefix("./").unwrap_or(&path).to_path_buf(); if relative.as_os_str().is_empty() { continue; @@ -239,9 +275,51 @@ where std::fs::create_dir_all(parent)?; } - // Extract the file - entry.unpack(&dest_path)?; - extracted_files.push(dest_path.to_string_lossy().to_string()); + // A hard link shares its content with an earlier entry of the + // archive: tar stores the target path relative to the archive root, + // 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; @@ -251,23 +329,22 @@ where } } - Ok(ExtractedArchive { - files: extracted_files, - in_place, - }) + Ok(()) } fn extract_archive( path: &Path, dest: &Path, progress: ProgressCallback<'_>, -) -> Result> { +) -> Result<(), Box> { let filename = path.file_name().unwrap().to_string_lossy(); if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { extract_tar_archive(path, dest, progress, GzDecoder::new) } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { 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 { Err(format!("Unsupported archive format: {}", filename).into()) } @@ -562,66 +639,36 @@ async fn fetch_archive_sources( ) .await?; - // Extract all tar archives, merging extracted directories - if file.name.ends_with(".tar.gz") || file.name.ends_with(".tar.xz") { + // Extract all tar archives. Every tar of the source package matches + // (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 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' if file.name.contains("debian.tar.") { continue; } - // Archives with './'-prefixed entries are already laid out - // directly in the package directory; only 'package-version/' - // style ones need their contents relocated - if !extracted.in_place { - // List root directories extracted and use the first one as the source directory - debug!("Root directories extracted:"); - let mut source_dir: Option = None; - for file in &extracted.files { - let path = Path::new(file); - // 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)?; - } - } - } + // 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)?; } // Extract and apply .diff.gz if present (old packages) @@ -946,39 +993,54 @@ mod tests { fn build_tar(entries: &[(&str, &[u8])]) -> Vec { let mut out = Vec::new(); for (name, data) in entries { - 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] = 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]); + push_tar_entry(&mut out, name, b'0', data, None); } - // End-of-archive marker: two zero-filled blocks out.extend_from_slice(&[0u8; 1024]); 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, + 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] fn test_extract_tar_rejects_parent_dir_traversal() { let temp_dir = tempfile::tempdir().unwrap(); @@ -1056,7 +1118,7 @@ mod tests { .unwrap(); 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!( 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(), "#!/m" ); - assert!(extracted.in_place); - assert!( - extracted.files.contains( - &dest - .join("hello-1.0/file.txt") - .to_string_lossy() - .to_string() - ) + } + + #[test] + fn test_extract_tar_hardlink_resolved_against_archive_root() { + // Regression: a hard link entry (e.g. the jq orig tarball) aborted + // the whole extraction when its target was not found where the + // 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!( - extracted - .files - .contains(&dest.join("debian/rules").to_string_lossy().to_string()) + tar_bytes.extend_from_slice(&[0u8; 1024]); + std::fs::write(&tar_path, tar_bytes).unwrap(); + + 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()); + } }