From d74454f7e9a19c9434c6277964c1e2e1f7b01f0a Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 19 Jan 2026 09:57:39 +0000 Subject: [PATCH 01/16] Merge pull request #1145 from adamhathcock/copilot/add-leaveopen-parameter-lzipstream Add leaveOpen parameter to LZipStream and BZip2Stream --- .../Compressors/BZip2/BZip2Stream.cs | 18 +- .../Compressors/BZip2/CBZip2InputStream.cs | 14 +- .../Compressors/BZip2/CBZip2OutputStream.cs | 13 +- .../Compressors/LZMA/LZipStream.cs | 8 +- .../Compressors/LZMA/LzmaStream.cs | 36 ++- .../Streams/DisposalTests.cs | 17 +- .../Streams/LeaveOpenBehaviorTests.cs | 226 ++++++++++++++++++ 7 files changed, 309 insertions(+), 23 deletions(-) create mode 100644 tests/SharpCompress.Test/Streams/LeaveOpenBehaviorTests.cs diff --git a/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs b/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs index e3325f27..b1c1de00 100644 --- a/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs +++ b/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs @@ -30,6 +30,7 @@ public sealed class BZip2Stream : Stream, IStreamStack private readonly Stream stream; private bool isDisposed; + private readonly bool leaveOpen; /// /// Create a BZip2Stream @@ -37,19 +38,30 @@ public sealed class BZip2Stream : Stream, IStreamStack /// The stream to read from /// Compression Mode /// Decompress Concatenated - public BZip2Stream(Stream stream, CompressionMode compressionMode, bool decompressConcatenated) + /// Leave the stream open after disposing + public BZip2Stream( + Stream stream, + CompressionMode compressionMode, + bool decompressConcatenated, + bool leaveOpen = false + ) { #if DEBUG_STREAMS this.DebugConstruct(typeof(BZip2Stream)); #endif + this.leaveOpen = leaveOpen; Mode = compressionMode; if (Mode == CompressionMode.Compress) { - this.stream = new CBZip2OutputStream(stream); + this.stream = new CBZip2OutputStream(stream, 9, leaveOpen); } else { - this.stream = new CBZip2InputStream(stream, decompressConcatenated); + this.stream = new CBZip2InputStream( + stream, + decompressConcatenated, + leaveOpen: leaveOpen + ); } } diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs index e466cc07..e3104408 100644 --- a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs +++ b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs @@ -168,6 +168,7 @@ internal class CBZip2InputStream : Stream, IStreamStack private int computedBlockCRC, computedCombinedCRC; private readonly bool decompressConcatenated; + private readonly bool leaveOpen; private int i2, count, @@ -181,9 +182,10 @@ internal class CBZip2InputStream : Stream, IStreamStack private char z; private bool isDisposed; - public CBZip2InputStream(Stream zStream, bool decompressConcatenated) + public CBZip2InputStream(Stream zStream, bool decompressConcatenated, bool leaveOpen = false) { this.decompressConcatenated = decompressConcatenated; + this.leaveOpen = leaveOpen; ll8 = null; tt = null; BsSetStream(zStream); @@ -207,7 +209,10 @@ internal class CBZip2InputStream : Stream, IStreamStack this.DebugDispose(typeof(CBZip2InputStream)); #endif base.Dispose(disposing); - bsStream?.Dispose(); + if (!leaveOpen) + { + bsStream?.Dispose(); + } } internal static int[][] InitIntArray(int n1, int n2) @@ -398,7 +403,10 @@ internal class CBZip2InputStream : Stream, IStreamStack private void BsFinishedWithStream() { - bsStream?.Dispose(); + if (!leaveOpen) + { + bsStream?.Dispose(); + } bsStream = null; } diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2OutputStream.cs b/src/SharpCompress/Compressors/BZip2/CBZip2OutputStream.cs index 7ee38b81..e0500adc 100644 --- a/src/SharpCompress/Compressors/BZip2/CBZip2OutputStream.cs +++ b/src/SharpCompress/Compressors/BZip2/CBZip2OutputStream.cs @@ -341,12 +341,14 @@ internal sealed class CBZip2OutputStream : Stream, IStreamStack private int currentChar = -1; private int runLength; + private readonly bool leaveOpen; - public CBZip2OutputStream(Stream inStream) - : this(inStream, 9) { } + public CBZip2OutputStream(Stream inStream, bool leaveOpen = false) + : this(inStream, 9, leaveOpen) { } - public CBZip2OutputStream(Stream inStream, int inBlockSize) + public CBZip2OutputStream(Stream inStream, int inBlockSize, bool leaveOpen = false) { + this.leaveOpen = leaveOpen; block = null; quadrant = null; zptr = null; @@ -481,7 +483,10 @@ internal sealed class CBZip2OutputStream : Stream, IStreamStack this.DebugDispose(typeof(CBZip2OutputStream)); #endif Dispose(); - bsStream?.Dispose(); + if (!leaveOpen) + { + bsStream?.Dispose(); + } bsStream = null; } } diff --git a/src/SharpCompress/Compressors/LZMA/LZipStream.cs b/src/SharpCompress/Compressors/LZMA/LZipStream.cs index e409e39e..baac44f8 100644 --- a/src/SharpCompress/Compressors/LZMA/LZipStream.cs +++ b/src/SharpCompress/Compressors/LZMA/LZipStream.cs @@ -46,11 +46,13 @@ public sealed class LZipStream : Stream, IStreamStack private long _writeCount; private readonly Stream? _originalStream; + private readonly bool _leaveOpen; - public LZipStream(Stream stream, CompressionMode mode) + public LZipStream(Stream stream, CompressionMode mode, bool leaveOpen = false) { Mode = mode; _originalStream = stream; + _leaveOpen = leaveOpen; if (mode == CompressionMode.Decompress) { @@ -60,7 +62,7 @@ public sealed class LZipStream : Stream, IStreamStack throw new InvalidFormatException("Not an LZip stream"); } var properties = GetProperties(dSize); - _stream = new LzmaStream(properties, stream); + _stream = new LzmaStream(properties, stream, leaveOpen: leaveOpen); } else { @@ -127,7 +129,7 @@ public sealed class LZipStream : Stream, IStreamStack { Finish(); _stream.Dispose(); - if (Mode == CompressionMode.Compress) + if (Mode == CompressionMode.Compress && !_leaveOpen) { _originalStream?.Dispose(); } diff --git a/src/SharpCompress/Compressors/LZMA/LzmaStream.cs b/src/SharpCompress/Compressors/LZMA/LzmaStream.cs index 77e4c494..28e100a3 100644 --- a/src/SharpCompress/Compressors/LZMA/LzmaStream.cs +++ b/src/SharpCompress/Compressors/LZMA/LzmaStream.cs @@ -35,6 +35,7 @@ public class LzmaStream : Stream, IStreamStack private readonly Stream _inputStream; private readonly long _inputSize; private readonly long _outputSize; + private readonly bool _leaveOpen; private readonly int _dictionarySize; private readonly OutWindow _outWindow = new(); @@ -56,14 +57,28 @@ public class LzmaStream : Stream, IStreamStack private readonly Encoder _encoder; private bool _isDisposed; - public LzmaStream(byte[] properties, Stream inputStream) - : this(properties, inputStream, -1, -1, null, properties.Length < 5) { } + public LzmaStream(byte[] properties, Stream inputStream, bool leaveOpen = false) + : this(properties, inputStream, -1, -1, null, properties.Length < 5, leaveOpen) { } - public LzmaStream(byte[] properties, Stream inputStream, long inputSize) - : this(properties, inputStream, inputSize, -1, null, properties.Length < 5) { } + public LzmaStream(byte[] properties, Stream inputStream, long inputSize, bool leaveOpen = false) + : this(properties, inputStream, inputSize, -1, null, properties.Length < 5, leaveOpen) { } - public LzmaStream(byte[] properties, Stream inputStream, long inputSize, long outputSize) - : this(properties, inputStream, inputSize, outputSize, null, properties.Length < 5) { } + public LzmaStream( + byte[] properties, + Stream inputStream, + long inputSize, + long outputSize, + bool leaveOpen = false + ) + : this( + properties, + inputStream, + inputSize, + outputSize, + null, + properties.Length < 5, + leaveOpen + ) { } public LzmaStream( byte[] properties, @@ -71,13 +86,15 @@ public class LzmaStream : Stream, IStreamStack long inputSize, long outputSize, Stream presetDictionary, - bool isLzma2 + bool isLzma2, + bool leaveOpen = false ) { _inputStream = inputStream; _inputSize = inputSize; _outputSize = outputSize; _isLzma2 = isLzma2; + _leaveOpen = leaveOpen; #if DEBUG_STREAMS this.DebugConstruct(typeof(LzmaStream)); @@ -179,7 +196,10 @@ public class LzmaStream : Stream, IStreamStack { _position = _encoder.Code(null, true); } - _inputStream?.Dispose(); + if (!_leaveOpen) + { + _inputStream?.Dispose(); + } _outWindow.Dispose(); } base.Dispose(disposing); diff --git a/tests/SharpCompress.Test/Streams/DisposalTests.cs b/tests/SharpCompress.Test/Streams/DisposalTests.cs index 9d30ab76..08995892 100644 --- a/tests/SharpCompress.Test/Streams/DisposalTests.cs +++ b/tests/SharpCompress.Test/Streams/DisposalTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using SharpCompress.Common; using SharpCompress.Compressors; +using SharpCompress.Compressors.BZip2; using SharpCompress.Compressors.Deflate; using SharpCompress.Compressors.LZMA; using SharpCompress.Compressors.Lzw; @@ -152,9 +153,21 @@ public class DisposalTests [Fact] public void LZipStream_Disposal() { - // LZipStream always disposes inner stream + // LZipStream now supports leaveOpen parameter // Use Compress mode to avoid need for valid input header - VerifyAlwaysDispose(stream => new LZipStream(stream, CompressionMode.Compress)); + VerifyStreamDisposal( + (stream, leaveOpen) => new LZipStream(stream, CompressionMode.Compress, leaveOpen) + ); + } + + [Fact] + public void BZip2Stream_Disposal() + { + // BZip2Stream now supports leaveOpen parameter + VerifyStreamDisposal( + (stream, leaveOpen) => + new BZip2Stream(stream, CompressionMode.Compress, false, leaveOpen) + ); } [Fact] diff --git a/tests/SharpCompress.Test/Streams/LeaveOpenBehaviorTests.cs b/tests/SharpCompress.Test/Streams/LeaveOpenBehaviorTests.cs new file mode 100644 index 00000000..76e0565e --- /dev/null +++ b/tests/SharpCompress.Test/Streams/LeaveOpenBehaviorTests.cs @@ -0,0 +1,226 @@ +using System; +using System.IO; +using System.Text; +using SharpCompress.Compressors; +using SharpCompress.Compressors.BZip2; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class LeaveOpenBehaviorTests +{ + private static byte[] CreateTestData() => + Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog"); + + [Fact] + public void BZip2Stream_Compress_LeaveOpen_False() + { + using var innerStream = new TestStream(new MemoryStream()); + using ( + var bzip2 = new BZip2Stream( + innerStream, + CompressionMode.Compress, + false, + leaveOpen: false + ) + ) + { + bzip2.Write(CreateTestData(), 0, CreateTestData().Length); + bzip2.Finish(); + } + + Assert.True(innerStream.IsDisposed, "Inner stream should be disposed when leaveOpen=false"); + } + + [Fact] + public void BZip2Stream_Compress_LeaveOpen_True() + { + using var innerStream = new TestStream(new MemoryStream()); + byte[] compressed; + using ( + var bzip2 = new BZip2Stream( + innerStream, + CompressionMode.Compress, + false, + leaveOpen: true + ) + ) + { + bzip2.Write(CreateTestData(), 0, CreateTestData().Length); + bzip2.Finish(); + } + + Assert.False( + innerStream.IsDisposed, + "Inner stream should NOT be disposed when leaveOpen=true" + ); + + // Should be able to read the compressed data + innerStream.Position = 0; + compressed = new byte[innerStream.Length]; + innerStream.Read(compressed, 0, compressed.Length); + Assert.True(compressed.Length > 0); + } + + [Fact] + public void BZip2Stream_Decompress_LeaveOpen_False() + { + // First compress some data + var memStream = new MemoryStream(); + using (var bzip2 = new BZip2Stream(memStream, CompressionMode.Compress, false, true)) + { + bzip2.Write(CreateTestData(), 0, CreateTestData().Length); + bzip2.Finish(); + } + + memStream.Position = 0; + using var innerStream = new TestStream(memStream); + var decompressed = new byte[CreateTestData().Length]; + + using ( + var bzip2 = new BZip2Stream( + innerStream, + CompressionMode.Decompress, + false, + leaveOpen: false + ) + ) + { + bzip2.Read(decompressed, 0, decompressed.Length); + } + + Assert.True(innerStream.IsDisposed, "Inner stream should be disposed when leaveOpen=false"); + Assert.Equal(CreateTestData(), decompressed); + } + + [Fact] + public void BZip2Stream_Decompress_LeaveOpen_True() + { + // First compress some data + var memStream = new MemoryStream(); + using (var bzip2 = new BZip2Stream(memStream, CompressionMode.Compress, false, true)) + { + bzip2.Write(CreateTestData(), 0, CreateTestData().Length); + bzip2.Finish(); + } + + memStream.Position = 0; + using var innerStream = new TestStream(memStream); + var decompressed = new byte[CreateTestData().Length]; + + using ( + var bzip2 = new BZip2Stream( + innerStream, + CompressionMode.Decompress, + false, + leaveOpen: true + ) + ) + { + bzip2.Read(decompressed, 0, decompressed.Length); + } + + Assert.False( + innerStream.IsDisposed, + "Inner stream should NOT be disposed when leaveOpen=true" + ); + Assert.Equal(CreateTestData(), decompressed); + + // Should still be able to use the stream + innerStream.Position = 0; + Assert.True(innerStream.CanRead); + } + + [Fact] + public void LZipStream_Compress_LeaveOpen_False() + { + using var innerStream = new TestStream(new MemoryStream()); + using (var lzip = new LZipStream(innerStream, CompressionMode.Compress, leaveOpen: false)) + { + lzip.Write(CreateTestData(), 0, CreateTestData().Length); + lzip.Finish(); + } + + Assert.True(innerStream.IsDisposed, "Inner stream should be disposed when leaveOpen=false"); + } + + [Fact] + public void LZipStream_Compress_LeaveOpen_True() + { + using var innerStream = new TestStream(new MemoryStream()); + byte[] compressed; + using (var lzip = new LZipStream(innerStream, CompressionMode.Compress, leaveOpen: true)) + { + lzip.Write(CreateTestData(), 0, CreateTestData().Length); + lzip.Finish(); + } + + Assert.False( + innerStream.IsDisposed, + "Inner stream should NOT be disposed when leaveOpen=true" + ); + + // Should be able to read the compressed data + innerStream.Position = 0; + compressed = new byte[innerStream.Length]; + innerStream.Read(compressed, 0, compressed.Length); + Assert.True(compressed.Length > 0); + } + + [Fact] + public void LZipStream_Decompress_LeaveOpen_False() + { + // First compress some data + var memStream = new MemoryStream(); + using (var lzip = new LZipStream(memStream, CompressionMode.Compress, true)) + { + lzip.Write(CreateTestData(), 0, CreateTestData().Length); + lzip.Finish(); + } + + memStream.Position = 0; + using var innerStream = new TestStream(memStream); + var decompressed = new byte[CreateTestData().Length]; + + using (var lzip = new LZipStream(innerStream, CompressionMode.Decompress, leaveOpen: false)) + { + lzip.Read(decompressed, 0, decompressed.Length); + } + + Assert.True(innerStream.IsDisposed, "Inner stream should be disposed when leaveOpen=false"); + Assert.Equal(CreateTestData(), decompressed); + } + + [Fact] + public void LZipStream_Decompress_LeaveOpen_True() + { + // First compress some data + var memStream = new MemoryStream(); + using (var lzip = new LZipStream(memStream, CompressionMode.Compress, true)) + { + lzip.Write(CreateTestData(), 0, CreateTestData().Length); + lzip.Finish(); + } + + memStream.Position = 0; + using var innerStream = new TestStream(memStream); + var decompressed = new byte[CreateTestData().Length]; + + using (var lzip = new LZipStream(innerStream, CompressionMode.Decompress, leaveOpen: true)) + { + lzip.Read(decompressed, 0, decompressed.Length); + } + + Assert.False( + innerStream.IsDisposed, + "Inner stream should NOT be disposed when leaveOpen=true" + ); + Assert.Equal(CreateTestData(), decompressed); + + // Should still be able to use the stream + innerStream.Position = 0; + Assert.True(innerStream.CanRead); + } +} From 7732fbb698b988aaf1edb7c729521930bd176253 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Thu, 22 Jan 2026 15:23:13 +0000 Subject: [PATCH 02/16] Merge pull request #1151 from adamhathcock/copilot/fix-entrystream-flush-issue Fix EntryStream.Dispose() throwing NotSupportedException on non-seekable streams --- src/SharpCompress/Common/EntryStream.cs | 36 +++++++++++-- .../SharpCompress.Test.csproj | 5 +- .../Zip/ZipReaderAsyncTests.cs | 54 +++++++++++++++++++ .../SharpCompress.Test/Zip/ZipReaderTests.cs | 46 ++++++++++++++++ 4 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/SharpCompress/Common/EntryStream.cs b/src/SharpCompress/Common/EntryStream.cs index 9e87e25e..c2262d81 100644 --- a/src/SharpCompress/Common/EntryStream.cs +++ b/src/SharpCompress/Common/EntryStream.cs @@ -79,11 +79,25 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - deflateStream.Flush(); //Deflate over reads. Knock it back + try + { + deflateStream.Flush(); //Deflate over reads. Knock it back + } + catch (NotSupportedException) + { + // Ignore: underlying stream does not support required operations for Flush + } } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - lzmaStream.Flush(); //Lzma over reads. Knock it back + try + { + lzmaStream.Flush(); //Lzma over reads. Knock it back + } + catch (NotSupportedException) + { + // Ignore: underlying stream does not support required operations for Flush + } } } #if DEBUG_STREAMS @@ -111,11 +125,25 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - await deflateStream.FlushAsync().ConfigureAwait(false); + try + { + await deflateStream.FlushAsync().ConfigureAwait(false); + } + catch (NotSupportedException) + { + // Ignore: underlying stream does not support required operations for Flush + } } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - await lzmaStream.FlushAsync().ConfigureAwait(false); + try + { + await lzmaStream.FlushAsync().ConfigureAwait(false); + } + catch (NotSupportedException) + { + // Ignore: underlying stream does not support required operations for Flush + } } } #if DEBUG_STREAMS diff --git a/tests/SharpCompress.Test/SharpCompress.Test.csproj b/tests/SharpCompress.Test/SharpCompress.Test.csproj index 6ee632bd..53ed7c7b 100644 --- a/tests/SharpCompress.Test/SharpCompress.Test.csproj +++ b/tests/SharpCompress.Test/SharpCompress.Test.csproj @@ -9,6 +9,9 @@ $(DefineConstants);DEBUG_STREAMS + + $(DefineConstants);LEGACY_DOTNET + $(DefineConstants);WINDOWS @@ -25,7 +28,7 @@ - + diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index 2892be57..91beecc0 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -251,4 +251,58 @@ public class ZipReaderAsyncTests : ReaderTests } Assert.Equal(8, count); } + + [Fact] + public async ValueTask EntryStream_Dispose_DoesNotThrow_OnNonSeekableStream_Deflate_Async() + { + // Since version 0.41.0: EntryStream.DisposeAsync() should not throw NotSupportedException + // when FlushAsync() fails on non-seekable streams (Deflate compression) + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + // This should not throw, even if internal FlushAsync() fails + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { +#if LEGACY_DOTNET + using var entryStream = await reader.OpenEntryStreamAsync(); +#else + await using var entryStream = await reader.OpenEntryStreamAsync(); +#endif + // Read some data + var buffer = new byte[1024]; + await entryStream.ReadAsync(buffer, 0, buffer.Length); + // DisposeAsync should not throw NotSupportedException + } + } + } + + [Fact] + public async ValueTask EntryStream_Dispose_DoesNotThrow_OnNonSeekableStream_LZMA_Async() + { + // Since version 0.41.0: EntryStream.DisposeAsync() should not throw NotSupportedException + // when FlushAsync() fails on non-seekable streams (LZMA compression) + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + // This should not throw, even if internal FlushAsync() fails + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { +#if LEGACY_DOTNET + using var entryStream = await reader.OpenEntryStreamAsync(); +#else + await using var entryStream = await reader.OpenEntryStreamAsync(); +#endif + // Read some data + var buffer = new byte[1024]; + await entryStream.ReadAsync(buffer, 0, buffer.Length); + // DisposeAsync should not throw NotSupportedException + } + } + } } diff --git a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs index fc87dc57..fe642ff7 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs @@ -436,4 +436,50 @@ public class ZipReaderTests : ReaderTests Assert.Equal(archiveKeys.OrderBy(k => k), readerKeys.OrderBy(k => k)); } } + + [Fact] + public void EntryStream_Dispose_DoesNotThrow_OnNonSeekableStream_Deflate() + { + // Since version 0.41.0: EntryStream.Dispose() should not throw NotSupportedException + // when Flush() fails on non-seekable streams (Deflate compression) + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); + using var reader = ReaderFactory.OpenReader(stream); + + // This should not throw, even if internal Flush() fails + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + using var entryStream = reader.OpenEntryStream(); + // Read some data + var buffer = new byte[1024]; + entryStream.Read(buffer, 0, buffer.Length); + // Dispose should not throw NotSupportedException + } + } + } + + [Fact] + public void EntryStream_Dispose_DoesNotThrow_OnNonSeekableStream_LZMA() + { + // Since version 0.41.0: EntryStream.Dispose() should not throw NotSupportedException + // when Flush() fails on non-seekable streams (LZMA compression) + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); + using var reader = ReaderFactory.OpenReader(stream); + + // This should not throw, even if internal Flush() fails + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + using var entryStream = reader.OpenEntryStream(); + // Read some data + var buffer = new byte[1024]; + entryStream.Read(buffer, 0, buffer.Length); + // Dispose should not throw NotSupportedException + } + } + } } From 21ce9a38e64c279b04a75c5ec6570fc495bb457e Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Fri, 23 Jan 2026 09:04:55 +0000 Subject: [PATCH 03/16] fix up tests --- tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs | 4 ++-- tests/SharpCompress.Test/Zip/ZipReaderTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index 91beecc0..7b8c476d 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -259,7 +259,7 @@ public class ZipReaderAsyncTests : ReaderTests // when FlushAsync() fails on non-seekable streams (Deflate compression) var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); - await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + using var reader = ReaderFactory.Open(stream); // This should not throw, even if internal FlushAsync() fails while (await reader.MoveToNextEntryAsync()) @@ -286,7 +286,7 @@ public class ZipReaderAsyncTests : ReaderTests // when FlushAsync() fails on non-seekable streams (LZMA compression) var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); - await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + using var reader = ReaderFactory.Open(stream); // This should not throw, even if internal FlushAsync() fails while (await reader.MoveToNextEntryAsync()) diff --git a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs index fe642ff7..af52b622 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs @@ -444,7 +444,7 @@ public class ZipReaderTests : ReaderTests // when Flush() fails on non-seekable streams (Deflate compression) var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); - using var reader = ReaderFactory.OpenReader(stream); + using var reader = ReaderFactory.Open(stream); // This should not throw, even if internal Flush() fails while (reader.MoveToNextEntry()) @@ -467,7 +467,7 @@ public class ZipReaderTests : ReaderTests // when Flush() fails on non-seekable streams (LZMA compression) var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); using Stream stream = new ForwardOnlyStream(File.OpenRead(path)); - using var reader = ReaderFactory.OpenReader(stream); + using var reader = ReaderFactory.Open(stream); // This should not throw, even if internal Flush() fails while (reader.MoveToNextEntry()) From d0f44839ffb1bc42ff61b298ff14c9465b865cd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:58:14 +0000 Subject: [PATCH 04/16] Initial plan From 41e0c151de5701abf176c73868af6c0075c4fa3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:04:09 +0000 Subject: [PATCH 05/16] Fix regression: archive iteration breaking when input stream throws in Flush() - Modified ZlibBaseStream.Flush() and FlushAsync() to only flush the underlying stream when in Writer mode - Added ThrowOnFlushStream mock for testing - Added regression tests for Deflate and LZMA compressed archives - All tests pass successfully Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- .../Compressors/Deflate/ZlibBaseStream.cs | 16 +++- src/SharpCompress/packages.lock.json | 12 +-- .../Mocks/ThrowOnFlushStream.cs | 73 +++++++++++++++++++ .../SharpCompress.Test/Zip/ZipReaderTests.cs | 48 ++++++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index dd6590b7..5099de54 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -586,7 +586,13 @@ internal class ZlibBaseStream : Stream, IStreamStack public override void Flush() { - _stream.Flush(); + // Only flush the underlying stream when in write mode + // Flushing input streams during read operations is not meaningful + // and can cause issues with forward-only/non-seekable streams + if (_streamMode == StreamMode.Writer) + { + _stream.Flush(); + } //rewind the buffer ((IStreamStack)this).Rewind(z.AvailableBytesIn); //unused z.AvailableBytesIn = 0; @@ -594,7 +600,13 @@ internal class ZlibBaseStream : Stream, IStreamStack public override async Task FlushAsync(CancellationToken cancellationToken) { - await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + // Only flush the underlying stream when in write mode + // Flushing input streams during read operations is not meaningful + // and can cause issues with forward-only/non-seekable streams + if (_streamMode == StreamMode.Writer) + { + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } //rewind the buffer ((IStreamStack)this).Rewind(z.AvailableBytesIn); //unused z.AvailableBytesIn = 0; diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 032c15c4..5e7ece33 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -216,9 +216,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "ISahzLHsHY7vrwqr2p1YWZ+gsxoBRtH7gWRDK8fDUst9pp2He0GiesaqEfeX0V8QMCJM3eNEHGGpnIcPjFo2NQ==" + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "sXdDtMf2qcnbygw9OdE535c2lxSxrZP8gO4UhDJ0xiJbl1wIqXS1OTcTDFTIJPOFd6Mhcm8gPEthqWGUxBsTqw==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -264,9 +264,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.22, )", - "resolved": "8.0.22", - "contentHash": "MhcMithKEiyyNkD2ZfbDZPmcOdi0GheGfg8saEIIEfD/fol3iHmcV8TsZkD4ZYz5gdUuoX4YtlVySUU7Sxl9SQ==" + "requested": "[8.0.23, )", + "resolved": "8.0.23", + "contentHash": "GqHiB1HbbODWPbY/lc5xLQH8siEEhNA0ptpJCC6X6adtAYNEzu5ZlqV3YHA3Gh7fuEwgA8XqVwMtH2KNtuQM1Q==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", diff --git a/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs new file mode 100644 index 00000000..2cf1a84c --- /dev/null +++ b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Test.Mocks; + +/// +/// A stream wrapper that throws NotSupportedException on Flush() calls. +/// This is used to test that archive iteration handles streams that don't support flushing. +/// +public class ThrowOnFlushStream : Stream +{ + private readonly Stream inner; + + public ThrowOnFlushStream(Stream inner) + { + this.inner = inner; + } + + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => throw new NotSupportedException("Flush not supported"); + + public override Task FlushAsync(CancellationToken cancellationToken) => + throw new NotSupportedException("FlushAsync not supported"); + + public override int Read(byte[] buffer, int offset, int count) => + inner.Read(buffer, offset, count); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => inner.ReadAsync(buffer, offset, count, cancellationToken); + +#if !NETFRAMEWORK && !NETSTANDARD2_0 + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) => inner.ReadAsync(buffer, cancellationToken); +#endif + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs index 30f1f16f..ab9b3afa 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs @@ -490,4 +490,52 @@ public class ZipReaderTests : ReaderTests } } } + + [Fact] + public void Archive_Iteration_DoesNotBreak_WhenFlushThrows_Deflate() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + using var reader = ReaderFactory.OpenReader(stream); + + var count = 0; + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } + + [Fact] + public void Archive_Iteration_DoesNotBreak_WhenFlushThrows_LZMA() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + using var reader = ReaderFactory.OpenReader(stream); + + var count = 0; + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } } From f2bb81d6113d066d44ac2078320836d076fc9d36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:42:44 +0000 Subject: [PATCH 06/16] Add async versions of archive iteration regression tests - Added Archive_Iteration_DoesNotBreak_WhenFlushThrows_Deflate_Async - Added Archive_Iteration_DoesNotBreak_WhenFlushThrows_LZMA_Async - Both async tests mirror the sync versions and pass successfully Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- .../Zip/ZipReaderAsyncTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index c4c5bb3a..aaa345ad 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -337,4 +337,52 @@ public class ZipReaderAsyncTests : ReaderTests } } } + + [Fact] + public async ValueTask Archive_Iteration_DoesNotBreak_WhenFlushThrows_Deflate_Async() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + var count = 0; + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } + + [Fact] + public async ValueTask Archive_Iteration_DoesNotBreak_WhenFlushThrows_LZMA_Async() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + var count = 0; + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } } From f1102dc98086b74be9e6ce0a781ab6b7839575c2 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 24 Jan 2026 10:01:49 +0000 Subject: [PATCH 07/16] Undoing https://github.com/adamhathcock/sharpcompress/pull/1151 --- src/SharpCompress/Common/EntryStream.cs | 36 +++---------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/src/SharpCompress/Common/EntryStream.cs b/src/SharpCompress/Common/EntryStream.cs index d265be00..e4de4ca9 100644 --- a/src/SharpCompress/Common/EntryStream.cs +++ b/src/SharpCompress/Common/EntryStream.cs @@ -79,25 +79,11 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - try - { - deflateStream.Flush(); //Deflate over reads. Knock it back - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + deflateStream.Flush(); //Deflate over reads. Knock it back } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - try - { - lzmaStream.Flush(); //Lzma over reads. Knock it back - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + lzmaStream.Flush(); //Lzma over reads. Knock it back } } #if DEBUG_STREAMS @@ -125,25 +111,11 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - try - { - await deflateStream.FlushAsync().ConfigureAwait(false); - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + await deflateStream.FlushAsync().ConfigureAwait(false); } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - try - { - await lzmaStream.FlushAsync().ConfigureAwait(false); - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + await lzmaStream.FlushAsync().ConfigureAwait(false); } } #if DEBUG_STREAMS From bf4217fde6005373ce5ade86ca3549fb73c93f00 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 24 Jan 2026 10:11:16 +0000 Subject: [PATCH 08/16] Merge pull request #1156 from adamhathcock/copilot/fix-sharpcompress-archive-iteration Fix silent iteration failure when input stream throws on Flush() # Conflicts: # src/SharpCompress/packages.lock.json --- src/SharpCompress/Common/EntryStream.cs | 36 +-------- .../Compressors/Deflate/ZlibBaseStream.cs | 16 +++- .../Mocks/ThrowOnFlushStream.cs | 73 +++++++++++++++++++ .../Zip/ZipReaderAsyncTests.cs | 48 ++++++++++++ .../SharpCompress.Test/Zip/ZipReaderTests.cs | 48 ++++++++++++ 5 files changed, 187 insertions(+), 34 deletions(-) create mode 100644 tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs diff --git a/src/SharpCompress/Common/EntryStream.cs b/src/SharpCompress/Common/EntryStream.cs index c2262d81..9e87e25e 100644 --- a/src/SharpCompress/Common/EntryStream.cs +++ b/src/SharpCompress/Common/EntryStream.cs @@ -79,25 +79,11 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - try - { - deflateStream.Flush(); //Deflate over reads. Knock it back - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + deflateStream.Flush(); //Deflate over reads. Knock it back } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - try - { - lzmaStream.Flush(); //Lzma over reads. Knock it back - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + lzmaStream.Flush(); //Lzma over reads. Knock it back } } #if DEBUG_STREAMS @@ -125,25 +111,11 @@ public class EntryStream : Stream, IStreamStack { if (ss.BaseStream() is SharpCompress.Compressors.Deflate.DeflateStream deflateStream) { - try - { - await deflateStream.FlushAsync().ConfigureAwait(false); - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + await deflateStream.FlushAsync().ConfigureAwait(false); } else if (ss.BaseStream() is SharpCompress.Compressors.LZMA.LzmaStream lzmaStream) { - try - { - await lzmaStream.FlushAsync().ConfigureAwait(false); - } - catch (NotSupportedException) - { - // Ignore: underlying stream does not support required operations for Flush - } + await lzmaStream.FlushAsync().ConfigureAwait(false); } } #if DEBUG_STREAMS diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index e2a757c6..f4406311 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -586,7 +586,13 @@ internal class ZlibBaseStream : Stream, IStreamStack public override void Flush() { - _stream.Flush(); + // Only flush the underlying stream when in write mode + // Flushing input streams during read operations is not meaningful + // and can cause issues with forward-only/non-seekable streams + if (_streamMode == StreamMode.Writer) + { + _stream.Flush(); + } //rewind the buffer ((IStreamStack)this).Rewind(z.AvailableBytesIn); //unused z.AvailableBytesIn = 0; @@ -594,7 +600,13 @@ internal class ZlibBaseStream : Stream, IStreamStack public override async Task FlushAsync(CancellationToken cancellationToken) { - await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + // Only flush the underlying stream when in write mode + // Flushing input streams during read operations is not meaningful + // and can cause issues with forward-only/non-seekable streams + if (_streamMode == StreamMode.Writer) + { + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } //rewind the buffer ((IStreamStack)this).Rewind(z.AvailableBytesIn); //unused z.AvailableBytesIn = 0; diff --git a/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs new file mode 100644 index 00000000..2cf1a84c --- /dev/null +++ b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Test.Mocks; + +/// +/// A stream wrapper that throws NotSupportedException on Flush() calls. +/// This is used to test that archive iteration handles streams that don't support flushing. +/// +public class ThrowOnFlushStream : Stream +{ + private readonly Stream inner; + + public ThrowOnFlushStream(Stream inner) + { + this.inner = inner; + } + + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => throw new NotSupportedException("Flush not supported"); + + public override Task FlushAsync(CancellationToken cancellationToken) => + throw new NotSupportedException("FlushAsync not supported"); + + public override int Read(byte[] buffer, int offset, int count) => + inner.Read(buffer, offset, count); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => inner.ReadAsync(buffer, offset, count, cancellationToken); + +#if !NETFRAMEWORK && !NETSTANDARD2_0 + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) => inner.ReadAsync(buffer, cancellationToken); +#endif + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index 7b8c476d..bf0aba87 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -305,4 +305,52 @@ public class ZipReaderAsyncTests : ReaderTests } } } + + [Fact] + public async ValueTask Archive_Iteration_DoesNotBreak_WhenFlushThrows_Deflate_Async() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + var count = 0; + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } + + [Fact] + public async ValueTask Archive_Iteration_DoesNotBreak_WhenFlushThrows_LZMA_Async() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + + var count = 0; + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } } diff --git a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs index af52b622..8623d117 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs @@ -482,4 +482,52 @@ public class ZipReaderTests : ReaderTests } } } + + [Fact] + public void Archive_Iteration_DoesNotBreak_WhenFlushThrows_Deflate() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + using var reader = ReaderFactory.OpenReader(stream); + + var count = 0; + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } + + [Fact] + public void Archive_Iteration_DoesNotBreak_WhenFlushThrows_LZMA() + { + // Regression test: since 0.41.0, archive iteration would silently break + // when the input stream throws NotSupportedException in Flush(). + // Only the first entry would be returned, then iteration would stop without exception. + var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); + using var fileStream = File.OpenRead(path); + using Stream stream = new ThrowOnFlushStream(fileStream); + using var reader = ReaderFactory.OpenReader(stream); + + var count = 0; + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + count++; + } + } + + // Should iterate through all entries, not just the first one + Assert.True(count > 1, $"Expected more than 1 entry, but got {count}"); + } } From 9caf7be928d908ff37959bf9a01c74d68f215c13 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 24 Jan 2026 10:23:02 +0000 Subject: [PATCH 09/16] Revert testing --- tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs | 4 ++-- tests/SharpCompress.Test/Zip/ZipReaderTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index bf0aba87..fa6f7cc7 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -315,7 +315,7 @@ public class ZipReaderAsyncTests : ReaderTests var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); using var fileStream = File.OpenRead(path); using Stream stream = new ThrowOnFlushStream(fileStream); - await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + using var reader = ReaderFactory.Open(stream); var count = 0; while (await reader.MoveToNextEntryAsync()) @@ -339,7 +339,7 @@ public class ZipReaderAsyncTests : ReaderTests var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); using var fileStream = File.OpenRead(path); using Stream stream = new ThrowOnFlushStream(fileStream); - await using var reader = ReaderFactory.OpenAsyncReader(new AsyncOnlyStream(stream)); + using var reader = ReaderFactory.Open(stream); var count = 0; while (await reader.MoveToNextEntryAsync()) diff --git a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs index 8623d117..f393e2f4 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderTests.cs @@ -492,7 +492,7 @@ public class ZipReaderTests : ReaderTests var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.dd.zip"); using var fileStream = File.OpenRead(path); using Stream stream = new ThrowOnFlushStream(fileStream); - using var reader = ReaderFactory.OpenReader(stream); + using var reader = ReaderFactory.Open(stream); var count = 0; while (reader.MoveToNextEntry()) @@ -516,7 +516,7 @@ public class ZipReaderTests : ReaderTests var path = Path.Combine(TEST_ARCHIVES_PATH, "Zip.lzma.dd.zip"); using var fileStream = File.OpenRead(path); using Stream stream = new ThrowOnFlushStream(fileStream); - using var reader = ReaderFactory.OpenReader(stream); + using var reader = ReaderFactory.Open(stream); var count = 0; while (reader.MoveToNextEntry()) From 44e4b1804ec2c64f865eca8547c5c81879a82282 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 26 Jan 2026 09:41:13 +0000 Subject: [PATCH 10/16] add check to see if we need to seek before hand --- src/SharpCompress/IO/BufferedSubStream.cs | 16 ++++++++++++++-- src/SharpCompress/IO/SharpCompressStream.cs | 1 - 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index f9216216..7e71207b 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -64,7 +64,14 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack _cacheLength = 0; return; } - Stream.Position = origin; + + // Only seek if we're not already at the correct position + // This avoids expensive seek operations when reading sequentially + if (Stream.Position != origin && Stream.CanSeek) + { + Stream.Position = origin; + } + _cacheLength = Stream.Read(_cache, 0, count); origin += _cacheLength; BytesLeftToRead -= _cacheLength; @@ -79,7 +86,12 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack _cacheLength = 0; return; } - Stream.Position = origin; + // Only seek if we're not already at the correct position + // This avoids expensive seek operations when reading sequentially + if (Stream.Position != origin && Stream.CanSeek) + { + Stream.Position = origin; + } _cacheLength = await Stream .ReadAsync(_cache, 0, count, cancellationToken) .ConfigureAwait(false); diff --git a/src/SharpCompress/IO/SharpCompressStream.cs b/src/SharpCompress/IO/SharpCompressStream.cs index 8261505f..101f7b16 100644 --- a/src/SharpCompress/IO/SharpCompressStream.cs +++ b/src/SharpCompress/IO/SharpCompressStream.cs @@ -257,7 +257,6 @@ public class SharpCompressStream : Stream, IStreamStack ValidateBufferState(); } - long orig = _internalPosition; long targetPos; // Calculate the absolute target position based on origin switch (origin) From a82fda98d789a708f8429fbc6b8acbebd8ea3823 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 26 Jan 2026 11:45:25 +0000 Subject: [PATCH 11/16] more testing and add pooling to cache --- src/SharpCompress/IO/BufferedSubStream.cs | 8 ++++++-- .../Streams/SharpCompressStreamTest.cs | 10 +++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index 7e71207b..2a5c917b 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -28,13 +29,16 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack #if DEBUG_STREAMS this.DebugDispose(typeof(BufferedSubStream)); #endif - if (disposing) { } + if (disposing) + { + ArrayPool.Shared.Return(_cache); + } base.Dispose(disposing); } private int _cacheOffset; private int _cacheLength; - private readonly byte[] _cache = new byte[32 << 10]; + private readonly byte[] _cache = ArrayPool.Shared.Rent(81920); private long origin; private long BytesLeftToRead { get; set; } diff --git a/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs b/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs index 1c4a8b57..44051226 100644 --- a/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs +++ b/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using SharpCompress.Compressors.LZMA; using SharpCompress.IO; +using SharpCompress.Test.Mocks; using Xunit; namespace SharpCompress.Test.Streams; @@ -64,7 +65,14 @@ public class SharpCompressStreamTests { createData(ms); - using (SharpCompressStream scs = new SharpCompressStream(ms, true, false, 0x10000)) + using ( + SharpCompressStream scs = new SharpCompressStream( + new ForwardOnlyStream(ms), + true, + false, + 0x10000 + ) + ) { IStreamStack stack = (IStreamStack)scs; From ddf37e82c2798ed5052bd8ea5574f3319d0617fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:06:38 +0000 Subject: [PATCH 12/16] Initial plan From 8c95f863cb8e39e1a52eebed187e1ba4f69c4042 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 26 Jan 2026 12:06:57 +0000 Subject: [PATCH 13/16] do CanSeek first --- src/SharpCompress/IO/BufferedSubStream.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index 2a5c917b..a504b7ef 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -71,7 +71,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack // Only seek if we're not already at the correct position // This avoids expensive seek operations when reading sequentially - if (Stream.Position != origin && Stream.CanSeek) + if (Stream.CanSeek && Stream.Position != origin) { Stream.Position = origin; } @@ -92,7 +92,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } // Only seek if we're not already at the correct position // This avoids expensive seek operations when reading sequentially - if (Stream.Position != origin && Stream.CanSeek) + if (Stream.CanSeek && Stream.Position != origin) { Stream.Position = origin; } From 875c2d76943dd5ca2d14c13d2b787fe1edb104b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:10:19 +0000 Subject: [PATCH 14/16] Fix BufferedSubStream double-dispose issue with ArrayPool Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- src/SharpCompress/IO/BufferedSubStream.cs | 24 ++++++++++++------- .../Streams/SharpCompressStreamTest.cs | 21 ++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index 2a5c917b..9ee22c05 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -29,17 +29,25 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack #if DEBUG_STREAMS this.DebugDispose(typeof(BufferedSubStream)); #endif - if (disposing) + if (_isDisposed) + { + return; + } + _isDisposed = true; + + if (disposing && _cache is not null) { ArrayPool.Shared.Return(_cache); + _cache = null; } base.Dispose(disposing); } private int _cacheOffset; private int _cacheLength; - private readonly byte[] _cache = ArrayPool.Shared.Rent(81920); + private byte[]? _cache = ArrayPool.Shared.Rent(81920); private long origin; + private bool _isDisposed; private long BytesLeftToRead { get; set; } @@ -61,7 +69,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack private void RefillCache() { - var count = (int)Math.Min(BytesLeftToRead, _cache.Length); + var count = (int)Math.Min(BytesLeftToRead, _cache!.Length); _cacheOffset = 0; if (count == 0) { @@ -83,7 +91,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack private async ValueTask RefillCacheAsync(CancellationToken cancellationToken) { - var count = (int)Math.Min(BytesLeftToRead, _cache.Length); + var count = (int)Math.Min(BytesLeftToRead, _cache!.Length); _cacheOffset = 0; if (count == 0) { @@ -118,7 +126,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } count = Math.Min(count, _cacheLength - _cacheOffset); - Buffer.BlockCopy(_cache, _cacheOffset, buffer, offset, count); + Buffer.BlockCopy(_cache!, _cacheOffset, buffer, offset, count); _cacheOffset += count; } @@ -136,7 +144,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } } - return _cache[_cacheOffset++]; + return _cache![_cacheOffset++]; } public override async Task ReadAsync( @@ -159,7 +167,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } count = Math.Min(count, _cacheLength - _cacheOffset); - Buffer.BlockCopy(_cache, _cacheOffset, buffer, offset, count); + Buffer.BlockCopy(_cache!, _cacheOffset, buffer, offset, count); _cacheOffset += count; } @@ -186,7 +194,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } count = Math.Min(count, _cacheLength - _cacheOffset); - _cache.AsSpan(_cacheOffset, count).CopyTo(buffer.Span); + _cache!.AsSpan(_cacheOffset, count).CopyTo(buffer.Span); _cacheOffset += count; } diff --git a/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs b/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs index 44051226..f74a6670 100644 --- a/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs +++ b/tests/SharpCompress.Test/Streams/SharpCompressStreamTest.cs @@ -97,4 +97,25 @@ public class SharpCompressStreamTests } } } + + [Fact] + public void BufferedSubStream_DoubleDispose_DoesNotCorruptArrayPool() + { + // This test verifies that calling Dispose multiple times on BufferedSubStream + // doesn't return the same array to the pool twice, which would cause pool corruption + byte[] data = new byte[0x10000]; + using (MemoryStream ms = new MemoryStream(data)) + { + var stream = new BufferedSubStream(ms, 0, data.Length); + + // First disposal + stream.Dispose(); + + // Second disposal should not throw or corrupt the pool + stream.Dispose(); + } + + // If we got here without an exception, the test passed + Assert.True(true); + } } From a0af0604d11ab1144e4be00ec73033e66013ab16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:11:16 +0000 Subject: [PATCH 15/16] Add disposal checks to RefillCache methods Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- src/SharpCompress/IO/BufferedSubStream.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index 9ee22c05..d2302b3a 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -69,6 +69,11 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack private void RefillCache() { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(BufferedSubStream)); + } + var count = (int)Math.Min(BytesLeftToRead, _cache!.Length); _cacheOffset = 0; if (count == 0) @@ -91,6 +96,11 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack private async ValueTask RefillCacheAsync(CancellationToken cancellationToken) { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(BufferedSubStream)); + } + var count = (int)Math.Min(BytesLeftToRead, _cache!.Length); _cacheOffset = 0; if (count == 0) From 56d3091688a459229a64735b1c580e909e5ece2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:12:08 +0000 Subject: [PATCH 16/16] Fix condition order to check CanSeek before Position Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- src/SharpCompress/IO/BufferedSubStream.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index d2302b3a..6bb3fa1d 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -84,7 +84,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack // Only seek if we're not already at the correct position // This avoids expensive seek operations when reading sequentially - if (Stream.Position != origin && Stream.CanSeek) + if (Stream.CanSeek && Stream.Position != origin) { Stream.Position = origin; } @@ -110,7 +110,7 @@ internal class BufferedSubStream : SharpCompressStream, IStreamStack } // Only seek if we're not already at the correct position // This avoids expensive seek operations when reading sequentially - if (Stream.Position != origin && Stream.CanSeek) + if (Stream.CanSeek && Stream.Position != origin) { Stream.Position = origin; }