diff --git a/tests/SharpCompress.Test/GZip/GZipWriterNonSeekableTests.cs b/tests/SharpCompress.Test/GZip/GZipWriterNonSeekableTests.cs new file mode 100644 index 00000000..3ee01e86 --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipWriterNonSeekableTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SharpCompress.Archives.GZip; +using SharpCompress.Test.Mocks; +using SharpCompress.Writers.GZip; +using Xunit; + +namespace SharpCompress.Test.GZip; + +/// +/// Regression tests for writing gzip streams to non-seekable (forward-only) output streams. +/// GZip is inherently a single forward stream with no seek-dependent layout, so streaming to +/// a must simply produce a valid, readable gzip stream. +/// +public class GZipWriterNonSeekableTests +{ + private const string EntryName = "content.txt"; + + private static byte[] CreateContent() => + Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("Hello streaming gzip! ", 500))); + + private static async Task WriteToNonSeekableAsync(byte[] content) + { + using var ms = new MemoryStream(); + await using (var writer = new GZipWriter(new ForwardOnlyStream(ms))) + { + using var source = new MemoryStream(content); + await writer.WriteAsync(EntryName, source, null); + } + return ms.ToArray(); + } + + private static byte[] WriteToNonSeekableSync(byte[] content) + { + using var ms = new MemoryStream(); + using (var writer = new GZipWriter(new ForwardOnlyStream(ms))) + { + using var source = new MemoryStream(content); + writer.Write(EntryName, source, null); + } + return ms.ToArray(); + } + + private static void AssertRoundTrips(byte[] gz, byte[] expected) + { + using var archive = GZipArchive.OpenArchive(new MemoryStream(gz)); + var entry = archive.Entries.First(); + using var extracted = new MemoryStream(); + using (var entryStream = entry.OpenEntryStream()) + { + entryStream.CopyTo(extracted); + } + Assert.Equal(expected, extracted.ToArray()); + } + + [Fact] + public async Task GZip_Async_NonSeekable_RoundTrips() + { + var content = CreateContent(); + var gz = await WriteToNonSeekableAsync(content); + AssertRoundTrips(gz, content); + } + + [Fact] + public void GZip_Sync_NonSeekable_RoundTrips() + { + var content = CreateContent(); + var gz = WriteToNonSeekableSync(content); + AssertRoundTrips(gz, content); + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterNonSeekableTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterNonSeekableTests.cs new file mode 100644 index 00000000..16c0d7a2 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterNonSeekableTests.cs @@ -0,0 +1,25 @@ +using System.IO; +using SharpCompress.Common; +using SharpCompress.Test.Mocks; +using SharpCompress.Writers.SevenZip; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +/// +/// 7z writing requires a seekable output stream so the signature header can be back-patched on +/// finalize (see SevenZipWriter.cs). Unlike Zip, it cannot fall back to a streaming layout, so +/// constructing a writer over a non-seekable output must fail fast. This pins that documented +/// limitation against silent regression. +/// +public class SevenZipWriterNonSeekableTests +{ + [Fact] + public void SevenZip_NonSeekable_Output_Throws() + { + using var ms = new MemoryStream(); + Assert.Throws(() => + new SevenZipWriter(new ForwardOnlyStream(ms), new SevenZipWriterOptions()) + ); + } +} diff --git a/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs b/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs new file mode 100644 index 00000000..454c20a6 --- /dev/null +++ b/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SharpCompress.Archives.Tar; +using SharpCompress.Common; +using SharpCompress.Test.Mocks; +using SharpCompress.Writers.Tar; +using Xunit; + +namespace SharpCompress.Test.Tar; + +/// +/// Regression tests for writing tar archives to non-seekable (forward-only) output streams. +/// Tar is a forward-only format with no header back-patching, so streaming to a +/// must produce a valid, readable archive. The sources are +/// seekable so the writer can derive each entry's size up front (see TarWriter.cs). +/// +public class TarWriterNonSeekableTests +{ + private static readonly DateTime FixedModificationTime = new(2024, 5, 15, 10, 30, 0); + + private static (string Name, byte[] Content)[] CreateStreamingTestEntries() => + [ + ( + "first.txt", + Encoding.UTF8.GetBytes( + string.Concat(Enumerable.Repeat("Hello streaming tar! ", 500)) + ) + ), + ( + "nested/second.txt", + Encoding.UTF8.GetBytes( + string.Concat(Enumerable.Repeat("Another entry with different content. ", 300)) + ) + ), + ]; + + private static async Task WriteArchiveToNonSeekableAsync( + (string Name, byte[] Content)[] entries + ) + { + using var ms = new MemoryStream(); + await using ( + var writer = new TarWriter( + new ForwardOnlyStream(ms), + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + foreach (var (name, content) in entries) + { + using var source = new MemoryStream(content); + await writer.WriteAsync(name, source, FixedModificationTime); + } + } + return ms.ToArray(); + } + + private static byte[] WriteArchiveToNonSeekableSync((string Name, byte[] Content)[] entries) + { + using var ms = new MemoryStream(); + using ( + var writer = new TarWriter( + new ForwardOnlyStream(ms), + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + foreach (var (name, content) in entries) + { + using var source = new MemoryStream(content); + writer.Write(name, source, FixedModificationTime); + } + } + return ms.ToArray(); + } + + private static async Task AssertRoundTripsAsync( + byte[] tar, + (string Name, byte[] Content)[] entries + ) + { + using var archive = TarArchive.OpenArchive(new MemoryStream(tar)); + var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Equal(entries.Length, fileEntries.Count); + foreach (var (name, content) in entries) + { + var entry = fileEntries.Single(e => e.Key == name); + using var extracted = new MemoryStream(); +#if LEGACY_DOTNET + using (var entryStream = await entry.OpenEntryStreamAsync()) +#else + await using (var entryStream = await entry.OpenEntryStreamAsync()) +#endif + { + await entryStream.CopyToAsync(extracted); + } + Assert.Equal(content, extracted.ToArray()); + } + } + + [Fact] + public async Task Tar_Async_NonSeekable_RoundTrips() + { + var entries = CreateStreamingTestEntries(); + var tar = await WriteArchiveToNonSeekableAsync(entries); + await AssertRoundTripsAsync(tar, entries); + } + + [Fact] + public async Task Tar_Sync_NonSeekable_RoundTrips() + { + var entries = CreateStreamingTestEntries(); + var tar = WriteArchiveToNonSeekableSync(entries); + await AssertRoundTripsAsync(tar, entries); + } + + [Fact] + public async Task Tar_Async_NonSeekable_Matches_Sync_Output() + { + var entries = CreateStreamingTestEntries(); + var asyncTar = await WriteArchiveToNonSeekableAsync(entries); + var syncTar = WriteArchiveToNonSeekableSync(entries); + + Assert.Equal(syncTar, asyncTar); + } +}