Files
sharpcompress/tests/SharpCompress.Test/Zip/ZipWriterNonSeekableTests.cs
2026-07-23 10:04:06 +01:00

189 lines
7.3 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SharpCompress.Archives.Zip;
using SharpCompress.Common;
using SharpCompress.Test.Mocks;
using SharpCompress.Writers;
using SharpCompress.Writers.Zip;
using Xunit;
namespace SharpCompress.Test.Zip;
/// <summary>
/// Regression tests for writing zips to non-seekable output streams, where entries must be
/// written in streaming layout: data-descriptor flag (bit 3) set in the local header and
/// central directory, zeroed local CRC/sizes, and a trailing PK\x07\x08 descriptor per entry.
/// The async writer used to derive the flag from its internal buffering MemoryStream instead
/// of the real output, producing archives that strict readers reject.
/// </summary>
public class ZipWriterNonSeekableTests
{
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 zip! ", 500))
)
),
(
"nested/second.txt",
Encoding.UTF8.GetBytes(
string.Concat(Enumerable.Repeat("Another entry with different content. ", 300))
)
),
];
private static async Task<byte[]> WriteArchiveToNonSeekableAsync(
(string Name, byte[] Content)[] entries
)
{
using var ms = new MemoryStream();
await using (
var writer = await WriterFactory.OpenAsyncWriter(
new ForwardOnlyStream(ms),
ArchiveType.Zip,
new ZipWriterOptions(CompressionType.Deflate)
)
)
{
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 = WriterFactory.OpenWriter(
new ForwardOnlyStream(ms),
ArchiveType.Zip,
new ZipWriterOptions(CompressionType.Deflate)
)
)
{
foreach (var (name, content) in entries)
{
using var source = new MemoryStream(content);
writer.Write(name, source, FixedModificationTime);
}
}
return ms.ToArray();
}
private static ushort ReadUInt16(byte[] data, int offset) =>
(ushort)(data[offset] | (data[offset + 1] << 8));
private static uint ReadUInt32(byte[] data, int offset) =>
(uint)(
data[offset]
| (data[offset + 1] << 8)
| (data[offset + 2] << 16)
| (data[offset + 3] << 24)
);
/// <summary>
/// Structurally validates a streamed (non-seekable output) zip: every local header and
/// central directory record must have the data-descriptor flag (bit 3, 0x0008) set, local
/// CRC/size fields must be zeroed (deferred), and a PK\x07\x08 descriptor with values
/// matching the central directory must follow each entry's data.
/// </summary>
private static void AssertStreamedZipLayout(byte[] zip, int expectedEntries)
{
// End of central directory record; the archive has no comment, so it is the last 22 bytes
var eocd = zip.Length - 22;
Assert.Equal(0x06054b50u, ReadUInt32(zip, eocd));
int entryCount = ReadUInt16(zip, eocd + 10);
Assert.Equal(expectedEntries, entryCount);
var cdOffset = checked((int)ReadUInt32(zip, eocd + 16));
var pos = cdOffset;
for (var i = 0; i < entryCount; i++)
{
Assert.Equal(0x02014b50u, ReadUInt32(zip, pos));
var cdFlags = ReadUInt16(zip, pos + 8);
Assert.True(
(cdFlags & 0x0008) != 0,
$"entry {i}: central directory flags 0x{cdFlags:x4} lack the data-descriptor bit"
);
var crc = ReadUInt32(zip, pos + 16);
var compressedSize = ReadUInt32(zip, pos + 20);
var uncompressedSize = ReadUInt32(zip, pos + 24);
var nameLength = ReadUInt16(zip, pos + 28);
var extraLength = ReadUInt16(zip, pos + 30);
var commentLength = ReadUInt16(zip, pos + 32);
var localHeaderOffset = checked((int)ReadUInt32(zip, pos + 42));
Assert.Equal(0x04034b50u, ReadUInt32(zip, localHeaderOffset));
var localFlags = ReadUInt16(zip, localHeaderOffset + 6);
Assert.True(
(localFlags & 0x0008) != 0,
$"entry {i}: local header flags 0x{localFlags:x4} lack the data-descriptor bit"
);
Assert.Equal(cdFlags, localFlags);
Assert.Equal(0u, ReadUInt32(zip, localHeaderOffset + 14)); // deferred CRC
Assert.Equal(0u, ReadUInt32(zip, localHeaderOffset + 18)); // deferred compressed size
Assert.Equal(0u, ReadUInt32(zip, localHeaderOffset + 22)); // deferred uncompressed size
var localNameLength = ReadUInt16(zip, localHeaderOffset + 26);
var localExtraLength = ReadUInt16(zip, localHeaderOffset + 28);
var descriptor =
localHeaderOffset + 30 + localNameLength + localExtraLength + (int)compressedSize;
Assert.Equal(0x08074b50u, ReadUInt32(zip, descriptor));
Assert.Equal(crc, ReadUInt32(zip, descriptor + 4));
Assert.Equal(compressedSize, ReadUInt32(zip, descriptor + 8));
Assert.Equal(uncompressedSize, ReadUInt32(zip, descriptor + 12));
pos += 46 + nameLength + extraLength + commentLength;
}
}
[Fact]
public async Task Zip_Async_NonSeekable_Writes_DataDescriptor_Flags()
{
var entries = CreateStreamingTestEntries();
var zip = await WriteArchiveToNonSeekableAsync(entries);
AssertStreamedZipLayout(zip, entries.Length);
// Round-trip: the archive must be readable and contents intact
using var archive = ZipArchive.OpenArchive(new MemoryStream(zip));
Assert.Equal(entries.Length, archive.Entries.Count());
foreach (var (name, content) in entries)
{
var entry = archive.Entries.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 Zip_Async_NonSeekable_Matches_Sync_Output()
{
var entries = CreateStreamingTestEntries();
var asyncZip = await WriteArchiveToNonSeekableAsync(entries);
var syncZip = WriteArchiveToNonSeekableSync(entries);
// The sync writer produces a correct streamed layout; the async writer must match it
Assert.Equal(syncZip, asyncZip);
}
}