Fix compressed tar archive entry enumeration via ArchiveFactory.OpenArchive

When reading compressed tar files (gzip, bzip2, xz, zstd, lzw, lzip) via
ArchiveFactory.OpenArchive, entry enumeration would fail with
IncompleteArchiveException after the second non-zero-sized entry.

Root cause: TarHeaderFactory.ReadHeader (streaming mode) created a
TarReadOnlySubStream for each entry's data but never disposed it before
reading the next 512-byte header. This left the underlying decompressed
stream positioned at the previous entry's data, causing header reads to
read garbage.

Fix:
1. TarHeaderFactory.ReadHeader / ReadHeaderAsync: track the previous
   TarReadOnlySubStream and dispose it (skipping any unread data + padding)
   before reading the next header. Double-dispose is safe because
   TarReadOnlySubStream guards against it with _isDisposed.

2. TarArchive.LoadEntries / LoadEntriesAsync: handle LongName entries
   differently for streaming (compressed) vs seekable (uncompressed) mode.
   In streaming mode the long filename is read immediately from
   header.PackedStream when the LongName header is received, before the
   foreach advances and ReadHeader disposes the packed stream. In seekable
   mode the existing behavior is preserved.

Agent-Logs-Url: https://github.com/adamhathcock/sharpcompress/sessions/8feadf56-d10d-431f-834d-bf1237a577ec

Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-08 08:46:35 +00:00
committed by GitHub
parent 21d2c406f3
commit 0fe4b6bf21
6 changed files with 157 additions and 18 deletions

View File

@@ -109,6 +109,7 @@ public partial class TarArchive
{
// Use async header reading for async-only streams
TarHeader? previousHeader = null;
string? previousLongName = null;
await foreach (
var header in TarHeaderFactory.ReadHeaderAsync(
streamingMode,
@@ -121,18 +122,48 @@ public partial class TarArchive
{
if (header.EntryType == EntryType.LongName)
{
previousHeader = header;
if (_compressionType != CompressionType.None)
{
// Streaming (compressed) mode: read the long name immediately
// from the PackedStream before the foreach loop advances and
// ReadHeaderAsync disposes it while skipping to the next header.
var longNameStream = header.PackedStream.NotNull();
#if NET8_0_OR_GREATER
await using (longNameStream.ConfigureAwait(false))
#else
using (longNameStream)
#endif
{
using var memoryStream = new MemoryStream();
await longNameStream
.CopyToAsync(memoryStream)
.ConfigureAwait(false);
previousLongName = ReaderOptions
.ArchiveEncoding.Decode(memoryStream.ToArray())
.TrimNulls();
}
}
else
{
// Seekable (uncompressed) mode: save the header; the long name
// will be read after the next entry header has been parsed.
previousHeader = header;
}
}
else
{
if (previousHeader != null)
if (previousLongName != null)
{
// Apply the long name that was pre-read in streaming mode.
header.Name = previousLongName;
previousLongName = null;
}
else if (previousHeader != null)
{
// Seekable mode: read the long name via the seekable stream now.
var entry = new TarArchiveEntry(
this,
new TarFilePart(
previousHeader,
_compressionType == CompressionType.None ? stream : null
),
new TarFilePart(previousHeader, stream),
CompressionType.None,
ReaderOptions
);

View File

@@ -117,6 +117,7 @@ public partial class TarArchive
stream.Position = 0;
}
TarHeader? previousHeader = null;
string? previousLongName = null;
foreach (
var header in TarHeaderFactory.ReadHeader(
_compressionType == CompressionType.None
@@ -131,18 +132,39 @@ public partial class TarArchive
{
if (header.EntryType == EntryType.LongName)
{
previousHeader = header;
if (_compressionType != CompressionType.None)
{
// Streaming (compressed) mode: read the long name immediately
// from the PackedStream before the foreach loop advances and
// ReadHeader disposes it while skipping to the next header.
using var longNameStream = header.PackedStream.NotNull();
using var memoryStream = new MemoryStream();
longNameStream.CopyTo(memoryStream, Constants.BufferSize);
previousLongName = ReaderOptions
.ArchiveEncoding.Decode(memoryStream.ToArray())
.TrimNulls();
}
else
{
// Seekable (uncompressed) mode: save the header; the long name
// will be read after the next entry header has been parsed.
previousHeader = header;
}
}
else
{
if (previousHeader != null)
if (previousLongName != null)
{
// Apply the long name that was pre-read in streaming mode.
header.Name = previousLongName;
previousLongName = null;
}
else if (previousHeader != null)
{
// Seekable mode: read the long name via the seekable stream now.
var entry = new TarArchiveEntry(
this,
new TarFilePart(
previousHeader,
_compressionType == CompressionType.None ? stream : null
),
new TarFilePart(previousHeader, stream),
CompressionType.None,
ReaderOptions
);

View File

@@ -19,11 +19,26 @@ internal static partial class TarHeaderFactory
using var reader = new AsyncBinaryReader(stream, leaveOpen: true);
#endif
// In streaming mode we track the previous entry's packed stream so we can
// advance past its data (and alignment padding) before reading the next header.
TarReadOnlySubStream? previousPackedStream = null;
while (true)
{
TarHeader? header = null;
try
{
// Dispose the previous packed stream so any unread entry data and its
// 512-byte alignment padding are skipped before we read the next header.
if (previousPackedStream != null)
{
#if LEGACY_DOTNET
previousPackedStream.Dispose();
#else
await previousPackedStream.DisposeAsync().ConfigureAwait(false);
#endif
previousPackedStream = null;
}
header = new TarHeader(archiveEncoding);
if (!await header.ReadAsync(reader).ConfigureAwait(false))
{
@@ -45,11 +60,13 @@ internal static partial class TarHeaderFactory
#if LEGACY_DOTNET
useSyncOverAsync = true;
#endif
header.PackedStream = new TarReadOnlySubStream(
var packedStream = new TarReadOnlySubStream(
stream,
header.Size,
useSyncOverAsync
);
header.PackedStream = packedStream;
previousPackedStream = packedStream;
}
break;
default:

View File

@@ -13,11 +13,20 @@ internal static partial class TarHeaderFactory
IArchiveEncoding archiveEncoding
)
{
// In streaming mode we track the previous entry's packed stream so we can
// advance past its data (and alignment padding) before reading the next header.
// Without this, the loop would re-read entry data bytes as a header.
TarReadOnlySubStream? previousPackedStream = null;
while (true)
{
TarHeader? header = null;
try
{
// Dispose the previous packed stream so any unread entry data and its
// 512-byte alignment padding are skipped before we read the next header.
previousPackedStream?.Dispose();
previousPackedStream = null;
var reader = new BinaryReader(stream, archiveEncoding.Default, leaveOpen: false);
header = new TarHeader(archiveEncoding);
@@ -37,11 +46,9 @@ internal static partial class TarHeaderFactory
break;
case StreamingMode.Streaming:
{
header.PackedStream = new TarReadOnlySubStream(
stream,
header.Size,
false
);
var packedStream = new TarReadOnlySubStream(stream, header.Size, false);
header.PackedStream = packedStream;
previousPackedStream = packedStream;
}
break;
default:

View File

@@ -275,4 +275,23 @@ public class TarArchiveAsyncTests : ArchiveTests
Assert.Equal(2, numberOfEntries);
}
[Theory]
[InlineData("Tar.tar.gz")]
[InlineData("Tar.tar.bz2")]
[InlineData("Tar.tar.xz")]
[InlineData("Tar.tar.zst")]
public async ValueTask TarArchive_CompressedTar_AllEntriesCanBeEnumerated_Async(
string archiveFileName
)
{
using Stream stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archiveFileName));
await using var archive = await ArchiveFactory.OpenAsyncArchive(stream);
Assert.Equal(ArchiveType.Tar, archive.Type);
// The compressed archives contain 6 entries (directories + files).
// This test verifies that entry enumeration does not fail after the first
// non-empty entry, which was broken for compressed (streaming) tars.
Assert.Equal(6, await archive.EntriesAsync.CountAsync());
}
}

View File

@@ -330,6 +330,49 @@ public class TarArchiveTests : ArchiveTests
Assert.NotEmpty(archive.Entries);
}
[Theory]
[InlineData("Tar.tar.gz")]
[InlineData("Tar.tar.bz2")]
[InlineData("Tar.tar.xz")]
[InlineData("Tar.tar.zst")]
public void TarArchive_CompressedTar_AllEntriesCanBeEnumerated(string archiveFileName)
{
using Stream stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archiveFileName));
using var archive = ArchiveFactory.OpenArchive(stream);
Assert.Equal(ArchiveType.Tar, archive.Type);
// The compressed archives contain 6 entries (directories + files).
// This test verifies that entry enumeration does not fail after the first
// non-empty entry, which was broken for compressed (streaming) tars.
Assert.Equal(6, archive.Entries.Count());
}
[Theory]
[InlineData("Tar.tar.gz")]
[InlineData("Tar.tar.bz2")]
[InlineData("Tar.tar.xz")]
[InlineData("Tar.tar.zst")]
public void TarArchive_CompressedTar_EntryContentsCanBeReadDuringIteration(
string archiveFileName
)
{
using Stream stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archiveFileName));
using var archive = ArchiveFactory.OpenArchive(stream);
// Compressed (streaming) tars must be read in order: open and read each
// entry's content before advancing to the next entry.
foreach (var entry in archive.Entries)
{
if (!entry.IsDirectory)
{
using var entryStream = entry.OpenEntryStream();
using var buffer = new MemoryStream();
entryStream.CopyTo(buffer);
Assert.Equal(entry.Size, buffer.Length);
}
}
}
[Fact]
public void TarReaderStreamRead_Autodetect_CompressedTar()
{