From 0fe4b6bf21ed250322889d2ecb7be0081a31119d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:46:35 +0000 Subject: [PATCH] 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> --- .../Archives/Tar/TarArchive.Async.cs | 43 ++++++++++++++++--- src/SharpCompress/Archives/Tar/TarArchive.cs | 34 ++++++++++++--- .../Common/Tar/TarHeaderFactory.Async.cs | 19 +++++++- .../Common/Tar/TarHeaderFactory.cs | 17 +++++--- .../Tar/TarArchiveAsyncTests.cs | 19 ++++++++ .../SharpCompress.Test/Tar/TarArchiveTests.cs | 43 +++++++++++++++++++ 6 files changed, 157 insertions(+), 18 deletions(-) diff --git a/src/SharpCompress/Archives/Tar/TarArchive.Async.cs b/src/SharpCompress/Archives/Tar/TarArchive.Async.cs index ac584e1b..24b2ec12 100644 --- a/src/SharpCompress/Archives/Tar/TarArchive.Async.cs +++ b/src/SharpCompress/Archives/Tar/TarArchive.Async.cs @@ -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 ); diff --git a/src/SharpCompress/Archives/Tar/TarArchive.cs b/src/SharpCompress/Archives/Tar/TarArchive.cs index fdd8ac3a..b5606807 100644 --- a/src/SharpCompress/Archives/Tar/TarArchive.cs +++ b/src/SharpCompress/Archives/Tar/TarArchive.cs @@ -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 ); diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs index b0a316f1..a57e239c 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs @@ -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: diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs index bcd3a5e9..7a507688 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs @@ -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: diff --git a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs index b9b7d6eb..78ff19b7 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs @@ -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()); + } } diff --git a/tests/SharpCompress.Test/Tar/TarArchiveTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveTests.cs index 008bb145..2d9f2cba 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveTests.cs @@ -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() {