diff --git a/src/new/orig.rs b/src/new/orig.rs index 5b8ef06..5e61cf0 100644 --- a/src/new/orig.rs +++ b/src/new/orig.rs @@ -11,6 +11,7 @@ //! travels in the component tarball instead and can be regenerated //! independently of the upstream sources. +use std::ffi::OsString; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -527,10 +528,34 @@ fn repack_tarball_file( result } +/// How the source tarball organizes its entries, resolved lazily from its +/// leading entries. +#[derive(Default)] +enum SourceLayout { + /// No entry has revealed the layout yet. + #[default] + Undecided, + /// A lone top-level directory entry, held back (name and header) until + /// the next entry either confirms it as the source tarball's own + /// top-level directory (classic layout) or proves the archive flat. + Probation(OsString, Box), + /// Classic layout: every entry nests under one top-level directory, + /// whose component is stripped from the repacked paths. + Nested(OsString), + /// Flat layout (`tar czf up.tar.gz file1 src/ ...`): entries already + /// sit at the top level and keep their whole path under the prefix. + Flat, +} + /// Rewrite every entry of the tar `stream` under the `-/` -/// top-level directory (whatever prefix the source tarball used) into the -/// xz-compressed tarball at `dest`. `.git` directories and tar metadata -/// leftovers are dropped, modes travel through. +/// top-level directory into the xz-compressed tarball at `dest`, whatever +/// the source tarball's layout. `.git` directories and tar metadata +/// leftovers are dropped, modes travel through. Classic archives nest +/// everything under one `pkg-1.0/` directory, which is stripped; FLAT +/// archives (`tar czf up.tar.gz file1 src/`) have no such directory, and +/// the old strip-first rule dropped their entries one and all, silently +/// writing an accepted-but-empty orig: flat entries now keep their whole +/// path under the new prefix. fn repack_tar_stream( stream: Box, name: &str, @@ -543,26 +568,99 @@ fn repack_tar_stream( let prefix = format!("{name}-{upstream_version}"); let mut archive = tar::Archive::new(stream); - for entry in archive.entries()? { + let mut layout = SourceLayout::Undecided; + 'entries: for entry in archive.entries()? { let mut entry = entry?; let original = entry.path()?.to_path_buf(); - // Drop the source tarball's top-level directory... - let rest: PathBuf = original - .components() - .skip(1) - .filter(|component| component.as_os_str() != ".git") - .collect(); - // ...skipping the top-level entry itself and any entry living - // inside a dropped directory (empty `rest` after a `.git` strip). - if rest.as_os_str().is_empty() { - continue; - } + // GNU tar's pax metadata leftover never travels. if original .file_name() .is_some_and(|name| name == "pax_global_header") { continue; } + // `.git` components are dropped wherever they appear. + let components: Vec<&std::ffi::OsStr> = original + .components() + .filter(|component| component.as_os_str() != ".git") + .map(|component| component.as_os_str()) + .collect(); + let Some(&first) = components.first() else { + // Nothing left, e.g. the `.git` directory entry itself: + // dropped like everything that lived inside it. + continue; + }; + let tail_is_empty = components.len() == 1; + let is_dir = entry.header().entry_type() == tar::EntryType::Directory; + + // The path the entry keeps under the new top-level directory: the + // source's own top-level component is stripped in the classic + // nested layout, everything else keeps its whole path. + let rest: PathBuf = 'resolve: { + match std::mem::take(&mut layout) { + SourceLayout::Undecided if tail_is_empty && is_dir => { + // A lone top-level directory entry: hold it back until + // the next entry shows whether it is the source + // tarball's own top-level directory (to strip, the + // classic `pkg-1.0/` layout) or a flat archive's + // top-level directory (to keep). + layout = SourceLayout::Probation( + first.to_os_string(), + Box::new(entry.header().clone()), + ); + continue 'entries; + } + SourceLayout::Undecided if tail_is_empty => { + // A top-level file: the archive is flat. + layout = SourceLayout::Flat; + break 'resolve components.iter().collect(); + } + SourceLayout::Undecided => { + // Entries nested right away, with no top-level + // directory entry: the first component names the + // source's own top-level directory. + layout = SourceLayout::Nested(first.to_os_string()); + break 'resolve components[1..].iter().collect(); + } + SourceLayout::Probation(claimed, mut header) => { + if first == claimed.as_os_str() && (is_dir || !tail_is_empty) { + // Confirmed: the held entry is the source tarball's + // own top-level directory, skipped as always. + layout = SourceLayout::Nested(claimed); + if tail_is_empty { + continue 'entries; + } + break 'resolve components[1..].iter().collect(); + } + // Refuted: a loose file or a second top-level directory + // proves the archive flat, and the held directory is a + // real one — it travels under the prefix. + let pending = format!("{prefix}/{}", claimed.to_string_lossy()); + builder.append_data(&mut header, &pending, std::io::empty())?; + layout = SourceLayout::Flat; + break 'resolve components.iter().collect(); + } + SourceLayout::Nested(top) => { + let under_top = first == top.as_os_str(); + layout = SourceLayout::Nested(top); + if under_top && tail_is_empty { + // The source top-level directory's own entry. + continue 'entries; + } + break 'resolve if under_top { + components[1..].iter().collect() + } else { + // Sibling top-level content in a nested archive, + // kept whole (the strip-first rule dropped it). + components.iter().collect() + }; + } + SourceLayout::Flat => { + layout = SourceLayout::Flat; + break 'resolve components.iter().collect(); + } + } + }; let new_path = format!("{prefix}/{}", rest.to_string_lossy()); let mut header = entry.header().clone(); @@ -588,6 +686,14 @@ fn repack_tar_stream( } } + // A top-level directory held back that no other entry ever confirmed or + // refuted (the archive holds nothing else): keep it rather than + // silently dropping it. + if let SourceLayout::Probation(claimed, mut header) = layout { + let pending = format!("{prefix}/{}", claimed.to_string_lossy()); + builder.append_data(&mut header, &pending, std::io::empty())?; + } + builder .finish() .map_err(|e| format!("failed to write '{}': {e}", dest.display()))?; @@ -726,6 +832,104 @@ mod tests { ); } + /// Append a single top-level regular-file entry to a tar builder. + fn append_flat_file( + builder: &mut tar::Builder, + name: &str, + contents: &str, + ) { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, name, contents.as_bytes()) + .unwrap(); + } + + /// Append a single top-level directory entry to a tar builder. + fn append_flat_dir(builder: &mut tar::Builder, name: &str) { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, name, std::io::empty()) + .unwrap(); + } + + /// Regression (flat tarball origin): `tar czf up.tar.gz file1 file2` + /// archives its entries at the TOP level, with no leading directory. + /// The old strip-first-component rule reduced every entry to nothing + /// and silently wrote an accepted-but-empty orig. Flat entries must + /// travel under the canonical `-/` top-level directory, + /// and no entry may remain outside it. + #[test] + fn repack_puts_flat_tarball_entries_under_the_prefix() { + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("mytool"); + std::fs::create_dir_all(&tree).unwrap(); + + let source = dir.path().join("flat.tar.gz"); + { + let file = std::fs::File::create(&source).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast()); + let mut builder = tar::Builder::new(encoder); + append_flat_file(&mut builder, "file1", "one\n"); + append_flat_file(&mut builder, "file2", "two\n"); + builder.into_inner().unwrap(); + } + + let dest = repack_tarball_file(&source, "mytool", "1.0.0", &tree).unwrap(); + let names = tarball_names(&dest); + assert!(names.iter().any(|n| n == "mytool-1.0.0/file1"), "{names:?}"); + assert!(names.iter().any(|n| n == "mytool-1.0.0/file2"), "{names:?}"); + // No entry may stay loose at the top level. + assert!( + names.iter().all(|n| n.starts_with("mytool-1.0.0/")), + "{names:?}" + ); + } + + /// A flat archive may also carry top-level DIRECTORIES: with `src/`, + /// `file1` and `src/main.rs` all at the top level, every path keeps its + /// shape under the prefix (`src/` → `/src/`). The directory + /// entry comes FIRST here, so the held-back-entry mechanism must + /// refute it as the source's own top-level directory for `src/` to + /// survive — a classic nested `pkg-1.0/` first entry is still skipped, + /// as the other repack tests assert. + #[test] + fn repack_keeps_flat_top_level_directories_under_the_prefix() { + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("mytool"); + std::fs::create_dir_all(&tree).unwrap(); + + let source = dir.path().join("flat-with-dir.tar.gz"); + { + let file = std::fs::File::create(&source).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast()); + let mut builder = tar::Builder::new(encoder); + append_flat_dir(&mut builder, "src"); + append_flat_file(&mut builder, "file1", "one\n"); + append_flat_file(&mut builder, "src/main.rs", "code\n"); + builder.into_inner().unwrap(); + } + + let dest = repack_tarball_file(&source, "mytool", "2.0.0", &tree).unwrap(); + let names = tarball_names(&dest); + assert!(names.iter().any(|n| n == "mytool-2.0.0/src"), "{names:?}"); + assert!( + names.iter().any(|n| n == "mytool-2.0.0/src/main.rs"), + "{names:?}" + ); + assert!(names.iter().any(|n| n == "mytool-2.0.0/file1"), "{names:?}"); + assert!( + names.iter().all(|n| n.starts_with("mytool-2.0.0/")), + "{names:?}" + ); + } + #[test] fn repack_drops_git_dirs_and_unsupported_entries() { let dir = tempfile::tempdir().unwrap();