diff --git a/docs/API.md b/docs/API.md index 15511e5d..5696961d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -485,6 +485,31 @@ using (var archive = ZipArchive.CreateArchive()) } ``` +### Buffered Forward-Only Streams + +`SharpCompressStream` can wrap streams with buffering for forward-only scenarios: + +```csharp +// Wrap a non-seekable stream with buffering +using (var bufferedStream = new SharpCompressStream(rawStream)) +{ + // Provides ring buffer functionality for reading ahead + // and seeking within buffered data + using (var reader = ReaderFactory.OpenReader(bufferedStream)) + { + while (reader.MoveToNextEntry()) + { + reader.WriteEntryToDirectory(@"C:\output"); + } + } +} +``` + +Useful for: +- Non-seekable streams (network streams, pipes) +- Forward-only reading with limited look-ahead +- Buffering unbuffered streams for better performance + ### Extract Specific Files ```csharp diff --git a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs index 4d18ac04..943547df 100644 --- a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs +++ b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs @@ -164,6 +164,12 @@ public partial class SevenZipArchive : AbstractArchive ReadAsync( byte[] buffer, diff --git a/src/SharpCompress/IO/SharpCompressStream.Create.cs b/src/SharpCompress/IO/SharpCompressStream.Create.cs index 3a886a80..8011641b 100644 --- a/src/SharpCompress/IO/SharpCompressStream.Create.cs +++ b/src/SharpCompress/IO/SharpCompressStream.Create.cs @@ -4,7 +4,7 @@ using SharpCompress.Common; namespace SharpCompress.IO; -internal partial class SharpCompressStream +public partial class SharpCompressStream { /// /// Creates a SharpCompressStream that acts as a passthrough wrapper. diff --git a/src/SharpCompress/IO/SharpCompressStream.cs b/src/SharpCompress/IO/SharpCompressStream.cs index a7c09072..2163e5f8 100644 --- a/src/SharpCompress/IO/SharpCompressStream.cs +++ b/src/SharpCompress/IO/SharpCompressStream.cs @@ -4,7 +4,19 @@ using SharpCompress.Common; namespace SharpCompress.IO; -internal partial class SharpCompressStream : Stream, IStreamStack +/// +/// Stream wrapper that provides optional ring-buffered reading for non-seekable +/// or forward-only streams, enabling limited backward seeking required by some +/// decompressors and archive formats. +/// +/// +/// In most cases, callers should obtain an instance via the static +/// SharpCompressStream.Create(...) methods rather than constructing this +/// class directly. The Create methods select an appropriate configuration +/// (such as passthrough vs buffered mode and buffer size) for the underlying +/// stream and usage scenario. +/// +public partial class SharpCompressStream : Stream, IStreamStack { public virtual Stream BaseStream() => stream; diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 29e7a1bd..27e9e496 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.0, )", - "resolved": "10.0.0", - "contentHash": "kICGrGYEzCNI3wPzfEXcwNHgTvlvVn9yJDhSdRK+oZQy4jvYH529u7O0xf5ocQKzOMjfS07+3z9PKRIjrFMJDA==" + "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/SevenZip/SevenZipArchiveTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs index ea9316e5..0b77c040 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs @@ -4,6 +4,7 @@ using System.Linq; using SharpCompress.Archives; using SharpCompress.Archives.SevenZip; using SharpCompress.Common; +using SharpCompress.Common.SevenZip; using SharpCompress.Factories; using SharpCompress.Readers; using Xunit; @@ -344,4 +345,53 @@ public class SevenZipArchiveTests : ArchiveTests // The critical check: within a single folder, the stream should NEVER be recreated Assert.Equal(0, streamRecreationsWithinFolder); // Folder stream should remain the same for all entries in the same folder } + + [Fact] + public void SevenZipArchive_EmptyStream_WriteToDirectory() + { + // This test specifically verifies that archives with empty-stream entries + // (files with size 0 and no compressed data) can be extracted without throwing + // NullReferenceException. This was previously failing because the folder was null + // for empty-stream entries. + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "7Zip.EmptyStream.7z"); + using var archive = SevenZipArchive.OpenArchive(testArchive); + + var emptyStreamFileCount = 0; + foreach (var entry in archive.Entries) + { + if (!entry.IsDirectory) + { + // Verify this is actually an empty-stream entry (HasStream == false) + var sevenZipEntry = entry as SevenZipEntry; + if (sevenZipEntry?.FilePart.Header.HasStream == false) + { + emptyStreamFileCount++; + } + + // This should not throw NullReferenceException + entry.WriteToDirectory(SCRATCH_FILES_PATH); + } + } + + // Ensure we actually tested empty-stream entries + Assert.True( + emptyStreamFileCount > 0, + "Test archive should contain at least one empty-stream entry" + ); + + // Verify that empty files were created + var extractedFiles = Directory.GetFiles( + SCRATCH_FILES_PATH, + "*", + SearchOption.AllDirectories + ); + Assert.NotEmpty(extractedFiles); + + // All extracted files should be empty (0 bytes) + foreach (var file in extractedFiles) + { + var fileInfo = new FileInfo(file); + Assert.Equal(0, fileInfo.Length); + } + } } diff --git a/tests/SharpCompress.Test/Xz/XZStreamAsyncTests.cs b/tests/SharpCompress.Test/Xz/XZStreamAsyncTests.cs index 7287983a..8f8e59b8 100644 --- a/tests/SharpCompress.Test/Xz/XZStreamAsyncTests.cs +++ b/tests/SharpCompress.Test/Xz/XZStreamAsyncTests.cs @@ -1,6 +1,7 @@ using System.IO; using System.Threading.Tasks; using SharpCompress.Compressors.Xz; +using SharpCompress.IO; using SharpCompress.Test.Mocks; using Xunit; @@ -34,4 +35,24 @@ public class XzStreamAsyncTests : XzTestsBase var uncompressed = await sr.ReadToEndAsync().ConfigureAwait(false); Assert.Equal(OriginalIndexed, uncompressed); } + + [Fact] + public async ValueTask CanReadNonSeekableStreamAsync() + { + var nonSeekable = new ForwardOnlyStream(new MemoryStream(Compressed)); + var xz = new XZStream(SharpCompressStream.Create(nonSeekable)); + using var sr = new StreamReader(new AsyncOnlyStream(xz)); + var uncompressed = await sr.ReadToEndAsync().ConfigureAwait(false); + Assert.Equal(Original, uncompressed); + } + + [Fact] + public async ValueTask CanReadNonSeekableEmptyStreamAsync() + { + var nonSeekable = new ForwardOnlyStream(new MemoryStream(CompressedEmpty)); + var xz = new XZStream(SharpCompressStream.Create(nonSeekable)); + using var sr = new StreamReader(new AsyncOnlyStream(xz)); + var uncompressed = await sr.ReadToEndAsync().ConfigureAwait(false); + Assert.Equal(OriginalEmpty, uncompressed); + } } diff --git a/tests/SharpCompress.Test/Xz/XZStreamTests.cs b/tests/SharpCompress.Test/Xz/XZStreamTests.cs index 02c5020c..80ad5dc3 100644 --- a/tests/SharpCompress.Test/Xz/XZStreamTests.cs +++ b/tests/SharpCompress.Test/Xz/XZStreamTests.cs @@ -1,5 +1,7 @@ using System.IO; using SharpCompress.Compressors.Xz; +using SharpCompress.IO; +using SharpCompress.Test.Mocks; using Xunit; namespace SharpCompress.Test.Xz; @@ -32,4 +34,24 @@ public class XzStreamTests : XzTestsBase var uncompressed = sr.ReadToEnd(); Assert.Equal(OriginalIndexed, uncompressed); } + + [Fact] + public void CanReadNonSeekableStream() + { + var nonSeekable = new ForwardOnlyStream(new MemoryStream(Compressed)); + var xz = new XZStream(SharpCompressStream.Create(nonSeekable)); + using var sr = new StreamReader(xz); + var uncompressed = sr.ReadToEnd(); + Assert.Equal(Original, uncompressed); + } + + [Fact] + public void CanReadNonSeekableEmptyStream() + { + var nonSeekable = new ForwardOnlyStream(new MemoryStream(CompressedEmpty)); + var xz = new XZStream(SharpCompressStream.Create(nonSeekable)); + using var sr = new StreamReader(xz); + var uncompressed = sr.ReadToEnd(); + Assert.Equal(OriginalEmpty, uncompressed); + } } diff --git a/tests/TestArchives/Archives/7Zip.EmptyStream.7z b/tests/TestArchives/Archives/7Zip.EmptyStream.7z new file mode 100644 index 00000000..c2d9bfa4 Binary files /dev/null and b/tests/TestArchives/Archives/7Zip.EmptyStream.7z differ