new: keep flat-tarball entries when repacking the orig tarball

The repack stripped the first path component of every entry, assuming a
single top-level directory: a flat archive ('tar czf up.tar.gz file1
file2') had all its entries dropped and wrote an accepted-but-empty
orig. The layout is now resolved from the leading entries (a lone
top-level directory is held back until the next entry confirms it as
the archive root or proves the archive flat) and flat entries keep
their whole path under the new top-level directory; classic archives
are repacked exactly as before.
This commit is contained in:
2026-09-17 17:45:25 +02:00
parent 174a13df39
commit 3501096107
+219 -15
View File
@@ -11,6 +11,7 @@
//! travels in the component tarball instead and can be regenerated //! travels in the component tarball instead and can be regenerated
//! independently of the upstream sources. //! independently of the upstream sources.
use std::ffi::OsString;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -527,10 +528,34 @@ fn repack_tarball_file(
result 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<tar::Header>),
/// 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 `<name>-<uver>/` /// Rewrite every entry of the tar `stream` under the `<name>-<uver>/`
/// top-level directory (whatever prefix the source tarball used) into the /// top-level directory into the xz-compressed tarball at `dest`, whatever
/// xz-compressed tarball at `dest`. `.git` directories and tar metadata /// the source tarball's layout. `.git` directories and tar metadata
/// leftovers are dropped, modes travel through. /// 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( fn repack_tar_stream(
stream: Box<dyn Read>, stream: Box<dyn Read>,
name: &str, name: &str,
@@ -543,26 +568,99 @@ fn repack_tar_stream(
let prefix = format!("{name}-{upstream_version}"); let prefix = format!("{name}-{upstream_version}");
let mut archive = tar::Archive::new(stream); 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 mut entry = entry?;
let original = entry.path()?.to_path_buf(); let original = entry.path()?.to_path_buf();
// Drop the source tarball's top-level directory... // GNU tar's pax metadata leftover never travels.
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;
}
if original if original
.file_name() .file_name()
.is_some_and(|name| name == "pax_global_header") .is_some_and(|name| name == "pax_global_header")
{ {
continue; 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 new_path = format!("{prefix}/{}", rest.to_string_lossy());
let mut header = entry.header().clone(); 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 builder
.finish() .finish()
.map_err(|e| format!("failed to write '{}': {e}", dest.display()))?; .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<W: std::io::Write>(
builder: &mut tar::Builder<W>,
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<W: std::io::Write>(builder: &mut tar::Builder<W>, 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 `<name>-<uver>/` 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/` → `<topdir>/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] #[test]
fn repack_drops_git_dirs_and_unsupported_entries() { fn repack_drops_git_dirs_and_unsupported_entries() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();