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() {