diff --git a/docs/USAGE.md b/docs/USAGE.md index f17bd9a6..0063c175 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -480,4 +480,6 @@ ARC LZW compressed data is buffered incrementally from the bytes actually presen ZIP Shrink output buffering grows with decoded output rather than the declared uncompressed size. Invalid codes and output-size mismatches are rejected. +7z header counts and coder property lengths are validated against their backing metadata before allocation. File counts are checked against parsed substreams and empty-stream bitmaps, including external metadata. Encoded headers and additional metadata are buffered incrementally as the decoder produces bytes, rather than preallocating the declared unpacked size. + LZMA/LZMA2 dictionary storage starts small and grows with decoded history, up to the supported dictionary size. These safeguards avoid allocations based solely on forged sizes; they do not impose an overall decompressed-output or process-memory limit. diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs index 30c5ad40..f8604921 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs @@ -193,6 +193,7 @@ internal sealed partial class ArchiveReader var packIndex = 0; foreach (var folder in folders) { + var unpackSize = GetMetadataSize(folder); var oldDataStartPos = dataStartPos; var myPackSizes = new long[folder._packStreams.Count]; for (var i = 0; i < myPackSizes.Length; i++) @@ -202,7 +203,7 @@ internal sealed partial class ArchiveReader dataStartPos += packSize; } - var outStream = await DecoderStreamHelper + using var outStream = await DecoderStreamHelper .CreateDecoderStreamAsync( _stream, oldDataStartPos, @@ -213,15 +214,29 @@ internal sealed partial class ArchiveReader ) .ConfigureAwait(false); - var unpackSize = checked((int)folder.GetUnpackSize()); - var data = new byte[unpackSize]; - await outStream - .ReadExactAsync(data, 0, data.Length, cancellationToken) - .ConfigureAwait(false); - if (outStream.ReadByte() >= 0) + // Grow only as the decoder actually produces metadata, never + // from the untrusted unpack-size declaration alone. + using var decoded = new PooledMemoryStream(); + var buffer = new byte[8192]; + for (var remaining = unpackSize; remaining > 0; ) + { + var count = Math.Min(buffer.Length, remaining); + await outStream + .ReadExactAsync(buffer, 0, count, cancellationToken) + .ConfigureAwait(false); + await decoded + .WriteAsync(buffer, 0, count, cancellationToken) + .ConfigureAwait(false); + remaining -= count; + } + if ( + await outStream.ReadAsync(buffer, 0, 1, cancellationToken).ConfigureAwait(false) + != 0 + ) { throw new InvalidFormatException("Decoded stream is longer than expected."); } + var data = decoded.ToArray(); dataVector.Add(data); if (folder.UnpackCrcDefined) @@ -319,6 +334,7 @@ internal sealed partial class ArchiveReader } var numFiles = ReadNum(); + ValidateFileCount(numFiles, unpackSizes.Count); db._files = new List(numFiles); for (var i = 0; i < numFiles; i++) { diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs index 3c9ddee9..5f2b7444 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs @@ -92,6 +92,7 @@ internal partial class ArchiveReader private BitVector ReadBitVector(int length) { + _currentReader.RequireBytes(((long)length + 7) / 8); var bits = new BitVector(length); byte data = 0; @@ -202,9 +203,16 @@ internal partial class ArchiveReader try { var numCoders = ReadNum(); + // CFolder.CheckStructure supports at most 32 coders/bind pairs, but runs + // after parsing. Enforce those limits before allocating their lists. + if (numCoders == 0 || numCoders > CFolder.MaxCoders) + { + throw new InvalidFormatException("Invalid number of 7z coders."); + } + _currentReader.RequireBytes(numCoders); folder._coders = new List(numCoders); - var numInStreams = 0; - var numOutStreams = 0; + long numInStreamsTotal = 0; + long numOutStreamsTotal = 0; for (var i = 0; i < numCoders; i++) { try @@ -241,6 +249,7 @@ internal partial class ArchiveReader if ((mainByte & 0x20) != 0) { var propsSize = ReadNum(); + _currentReader.RequireBytes(propsSize); coder._props = new byte[propsSize]; ReadBytes(coder._props, 0, propsSize); } @@ -250,30 +259,48 @@ internal partial class ArchiveReader throw new NotSupportedException(); } - numInStreams += coder._numInStreams; - numOutStreams += coder._numOutStreams; + if (coder._numInStreams == 0 || coder._numOutStreams == 0) + { + throw new InvalidFormatException("Invalid 7z coder stream count."); + } + numInStreamsTotal += coder._numInStreams; + numOutStreamsTotal += coder._numOutStreams; } finally { } } + if ( + numOutStreamsTotal - 1 > CFolder.MaxBindPairs + || numInStreamsTotal > int.MaxValue + || numInStreamsTotal < numOutStreamsTotal + ) + { + throw new InvalidFormatException("Invalid 7z folder stream counts."); + } + var numInStreams = (int)numInStreamsTotal; + var numOutStreams = (int)numOutStreamsTotal; var numBindPairs = numOutStreams - 1; + _currentReader.RequireBytes(2L * numBindPairs); folder._bindPairs = new List(numBindPairs); for (var i = 0; i < numBindPairs; i++) { var bp = new CBindPair(); bp._inIndex = ReadNum(); bp._outIndex = ReadNum(); + if ( + bp._inIndex >= numInStreams + || bp._outIndex >= numOutStreams + || folder.FindBindPairForInStream(bp._inIndex) >= 0 + || folder.FindBindPairForOutStream(bp._outIndex) >= 0 + ) + { + throw new InvalidFormatException("Invalid 7z bind pair."); + } folder._bindPairs.Add(bp); } - if (numInStreams < numBindPairs) - { - throw new NotSupportedException(); - } - var numPackStreams = numInStreams - numBindPairs; - //folder.PackStreams.Reserve(numPackStreams); if (numPackStreams == 1) { for (var i = 0; i < numInStreams; i++) @@ -292,9 +319,19 @@ internal partial class ArchiveReader } else { + _currentReader.RequireBytes(numPackStreams); + var packStreams = new HashSet(); for (var i = 0; i < numPackStreams; i++) { var num = ReadNum(); + if ( + num >= numInStreams + || folder.FindBindPairForInStream(num) >= 0 + || !packStreams.Add(num) + ) + { + throw new InvalidFormatException("Invalid 7z packed stream index."); + } folder._packStreams.Add(num); } } @@ -304,7 +341,23 @@ internal partial class ArchiveReader private List ReadHashDigests(int count) { + // An all-defined vector has no bitmap, but must have a CRC per item. + // Check it before ReadOptionalBitVector allocates the implicit bitmap. + var reader = _currentReader.Clone(); + if (reader.ReadByte() != 0) + { + reader.RequireBytes(4L * count); + } var defined = ReadOptionalBitVector(count); + long numDefined = 0; + for (var i = 0; i < count; i++) + { + if (defined[i]) + { + numDefined++; + } + } + _currentReader.RequireBytes(4 * numDefined); var digests = new List(count); for (var i = 0; i < count; i++) { @@ -336,6 +389,7 @@ internal partial class ArchiveReader var numPackStreams = ReadNum(); WaitAttribute(BlockType.Size); + _currentReader.RequireBytes(numPackStreams); packSizes = new List(numPackStreams); for (var i = 0; i < numPackStreams; i++) { @@ -382,8 +436,9 @@ internal partial class ArchiveReader { streamSwitch.Set(this, dataVector ?? []); - //folders.Clear(); - //folders.Reserve(numFolders); + // Folder definitions may live in an external metadata stream. + // Validate against the selected stream, not the outer header. + _currentReader.RequireBytes(numFolders); folders = new List(numFolders); var index = 0; for (var i = 0; i < numFolders; i++) @@ -405,6 +460,10 @@ internal partial class ArchiveReader var size = checked((long)ReadNumber()); folder._unpackSizes.Add(size); } + if (!folder.CheckStructure()) + { + throw new InvalidFormatException("Invalid 7z folder structure."); + } } for (; ; ) @@ -476,6 +535,21 @@ internal partial class ArchiveReader } } + // Every substream except the last in each folder needs an explicit + // size. Reject impossible counts before loops or digest allocations. + long numExplicitSizes = 0; + long numStreamsTotal = 0; + foreach (var count in numUnpackStreamsInFolders) + { + numExplicitSizes += Math.Max(0, count - 1); + numStreamsTotal += count; + } + if (numStreamsTotal > int.MaxValue || (numExplicitSizes != 0 && type != BlockType.Size)) + { + throw new InvalidFormatException("Invalid 7z substream count."); + } + _currentReader.RequireBytes(numExplicitSizes); + unpackSizes = new List(folders.Count); for (var i = 0; i < numUnpackStreamsInFolders.Count; i++) { @@ -492,6 +566,10 @@ internal partial class ArchiveReader if (type == BlockType.Size) { var size = checked((long)ReadNumber()); + if (size > folders[i].GetUnpackSize() - sum) + { + throw new InvalidFormatException("Invalid 7z substream size."); + } unpackSizes.Add(size); sum += size; } @@ -620,6 +698,67 @@ internal partial class ArchiveReader finally { } } + private static int GetMetadataSize(CFolder folder) + { + var size = folder.GetUnpackSize(); + if (size < 0 || size > int.MaxValue) + { + throw new InvalidFormatException("7z metadata is too large."); + } + return (int)size; + } + + private void ValidateFileCount(int numFiles, int numStreams) + { + // Nonempty files correspond to already parsed substreams. Empty files + // require one bit each in kEmptyStream, not one byte per file. Preflight + // that property before allocating CFileItem objects or implicit vectors. + var reader = _currentReader.Clone(); + var numEmptyStreams = 0; + var hasEmptyStreams = false; + for (; ; ) + { + var type = reader.ReadNumber(); + if (type == (ulong)BlockType.End) + { + break; + } + var size = reader.ReadNumber(); + if (size > (ulong)reader.Remaining) + { + throw new IncompleteArchiveException("Unexpected end of stream."); + } + var start = reader.Offset; + if (type == (ulong)BlockType.EmptyStream) + { + var bitmapSize = ((long)numFiles + 7) / 8; + if (hasEmptyStreams || size < (ulong)bitmapSize) + { + throw new InvalidFormatException("Invalid 7z empty stream bitmap."); + } + hasEmptyStreams = true; + byte bits = 0; + for (var i = 0; i < numFiles; i++) + { + if ((i & 7) == 0) + { + bits = reader.ReadByte(); + } + if ((bits & 0x80) != 0) + { + numEmptyStreams++; + } + bits <<= 1; + } + } + reader.SkipData((long)size - (reader.Offset - start)); + } + if ((long)numStreams + numEmptyStreams != numFiles) + { + throw new InvalidFormatException("7z file count does not match the stream count."); + } + } + private List ReadAndDecodePackedStreams(long baseOffset, IPasswordProvider pass) { try @@ -641,6 +780,7 @@ internal partial class ArchiveReader var packIndex = 0; foreach (var folder in folders) { + var unpackSize = GetMetadataSize(folder); var oldDataStartPos = dataStartPos; var myPackSizes = new long[folder._packStreams.Count]; for (var i = 0; i < myPackSizes.Length; i++) @@ -650,7 +790,7 @@ internal partial class ArchiveReader dataStartPos += packSize; } - var outStream = DecoderStreamHelper.CreateDecoderStream( + using var outStream = DecoderStreamHelper.CreateDecoderStream( _stream, oldDataStartPos, myPackSizes, @@ -658,13 +798,22 @@ internal partial class ArchiveReader pass ); - var unpackSize = checked((int)folder.GetUnpackSize()); - var data = new byte[unpackSize]; - outStream.ReadExact(data, 0, data.Length); + // Do not reserve the declared unpack size: malformed compressed + // metadata can advertise GiB of output without producing a byte. + using var decoded = new PooledMemoryStream(); + var buffer = new byte[8192]; + for (var remaining = unpackSize; remaining > 0; ) + { + var count = Math.Min(buffer.Length, remaining); + outStream.ReadExact(buffer, 0, count); + decoded.Write(buffer, 0, count); + remaining -= count; + } if (outStream.ReadByte() >= 0) { throw new InvalidFormatException("Decoded stream is longer than expected."); } + var data = decoded.ToArray(); dataVector.Add(data); if (folder.UnpackCrcDefined) @@ -756,6 +905,7 @@ internal partial class ArchiveReader } var numFiles = ReadNum(); + ValidateFileCount(numFiles, unpackSizes.Count); db._files = new List(numFiles); for (var i = 0; i < numFiles; i++) { diff --git a/src/SharpCompress/Common/SevenZip/CFolder.cs b/src/SharpCompress/Common/SevenZip/CFolder.cs index 683c27ca..50ef871e 100644 --- a/src/SharpCompress/Common/SevenZip/CFolder.cs +++ b/src/SharpCompress/Common/SevenZip/CFolder.cs @@ -6,6 +6,9 @@ namespace SharpCompress.Common.SevenZip; internal class CFolder { + internal const int MaxCoders = 32; + internal const int MaxBindPairs = 32; + internal List _coders = new(); internal List _bindPairs = new(); internal List _packStreams = new(); @@ -98,11 +101,9 @@ internal class CFolder public bool CheckStructure() { - const int kNumCodersMax = 32; // don't change it - const int kMaskSize = 32; // it must be >= kNumCodersMax - const int kNumBindsMax = 32; + const int kMaskSize = 32; // it must be >= MaxCoders - if (_coders.Count > kNumCodersMax || _bindPairs.Count > kNumBindsMax) + if (_coders.Count > MaxCoders || _bindPairs.Count > MaxBindPairs) { return false; } diff --git a/src/SharpCompress/Common/SevenZip/DataReader.cs b/src/SharpCompress/Common/SevenZip/DataReader.cs index 8788192b..85d59be5 100644 --- a/src/SharpCompress/Common/SevenZip/DataReader.cs +++ b/src/SharpCompress/Common/SevenZip/DataReader.cs @@ -45,6 +45,18 @@ internal class DataReader public int Offset { get; private set; } + public int Remaining => _ending - Offset; + + public DataReader Clone() => new(_buffer, Offset, Remaining); + + public void RequireBytes(long count) + { + if (count < 0 || count > Remaining) + { + throw new IncompleteArchiveException("Unexpected end of stream."); + } + } + public byte ReadByte() { if (Offset >= _ending) @@ -57,10 +69,7 @@ internal class DataReader public void ReadBytes(byte[] buffer, int offset, int length) { - if (length > _ending - Offset) - { - throw new IncompleteArchiveException("Unexpected end of stream."); - } + RequireBytes(length); while (length-- > 0) { @@ -70,10 +79,7 @@ internal class DataReader public void SkipData(long size) { - if (size > _ending - Offset) - { - throw new IncompleteArchiveException("Unexpected end of stream."); - } + RequireBytes(size); Offset += (int)size; } diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipHeaderAllocationTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipHeaderAllocationTests.cs new file mode 100644 index 00000000..18db66b5 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipHeaderAllocationTests.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Common.SevenZip; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Crypto; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +[CollectionDefinition(nameof(SevenZipHeaderAllocationTests), DisableParallelization = true)] +public class SevenZipHeaderAllocationCollection { } + +[Collection(nameof(SevenZipHeaderAllocationTests))] +public class SevenZipHeaderAllocationTests +{ + public static IEnumerable MalformedHeaders() + { + var count = Number(100_000); + yield return ["folders", Join([1, 4, 7, 11], count, [0])]; + yield return ["files", Join([1, 5], count, [0])]; + yield return ["coders", Join([1, 4, 7, 11, 1, 0], count)]; + yield return ["properties", Join([1, 4, 7, 11, 1, 0, 1, 0x21, 0x21], count)]; + yield return ["pack streams", Join([1, 4, 6, 0], count, [9, 0])]; + yield return ["input streams", Join([1, 4, 7, 11, 1, 0, 1, 0x11, 0], count, [1])]; + yield return ["output streams", Join([1, 4, 7, 11, 1, 0, 1, 0x11, 0, 1], count)]; + yield return ["zero coders", new byte[] { 1, 4, 7, 11, 1, 0, 0 }]; + yield return ["zero input streams", new byte[] { 1, 4, 7, 11, 1, 0, 1, 0x11, 0, 0, 1 }]; + yield return ["zero output streams", new byte[] { 1, 4, 7, 11, 1, 0, 1, 0x11, 0, 1, 0 }]; + yield return ["truncated empty bitmap", Join([1, 5], count, [14, 1, 0x80, 0])]; + // One valid Copy folder followed by forged per-folder substream counts. + byte[] folder = [1, 4, 7, 11, 1, 0, 1, 1, 0, 12, 0, 0, 8, 13]; + yield return ["substream CRCs", Join(folder, count, [10, 1])]; + yield return ["substream sizes", Join(folder, count, [9, 0])]; + yield return ["implicit substream CRCs", Join(folder, count, [0])]; + yield return ["substream size exceeds folder", Join(folder, [2, 9, 1, 0])]; + yield return ["invalid bind index", new byte[] { 1, 4, 7, 11, 1, 0, 2, 1, 0, 1, 0, 2, 0 }]; + yield return + [ + "invalid pack index", + new byte[] { 1, 4, 7, 11, 1, 0, 1, 0x11, 0, 2, 1, 0, 2 }, + ]; + yield return + [ + "duplicate pack index", + new byte[] { 1, 4, 7, 11, 1, 0, 1, 0x11, 0, 2, 1, 0, 0 }, + ]; + yield return + [ + "oversized decoded metadata", + MetadataHeader(true, (ulong)int.MaxValue + 1, 0, [1, 0]), + ]; + yield return + [ + "overflowing inputs", + Join( + [1, 4, 7, 11, 1, 0, 2, 0x11, 0], + Number(int.MaxValue), + [1, 0x11, 0], + Number(int.MaxValue), + [1] + ), + ]; + } + + [Theory] + [MemberData(nameof(MalformedHeaders))] + public void SevenZipArchive_InvalidHeaderCounts_RejectBeforeAllocation( + string name, + byte[] header + ) + { + using var stream = CreateArchive(header); + using var archive = ArchiveFactory.OpenArchive(stream); +#if !LEGACY_DOTNET + var before = GC.GetAllocatedBytesForCurrentThread(); +#endif + var exception = Record.Exception(() => archive.Entries.ToList()); + Assert.True( + exception is InvalidFormatException or IncompleteArchiveException, + $"{name}: {exception}" + ); +#if !LEGACY_DOTNET + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 512 * 1024, $"{name}: allocated {allocated} bytes."); +#endif + Assert.True(stream.CanRead); + } + + [Theory] + [MemberData(nameof(MalformedHeaders))] + public async Task SevenZipArchive_InvalidHeaderCounts_AsyncRejectBeforeAllocation( + string name, + byte[] header + ) + { + using var stream = CreateArchive(header); + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); +#if !LEGACY_DOTNET + var before = GC.GetTotalAllocatedBytes(precise: true); +#endif + var exception = await Record.ExceptionAsync(async () => + { + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) { } + }); + Assert.True( + exception is InvalidFormatException or IncompleteArchiveException, + $"{name}: {exception}" + ); +#if !LEGACY_DOTNET + var allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + Assert.True(allocated < 512 * 1024, $"{name}: allocated {allocated} bytes."); +#endif + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SevenZipArchive_TruncatedDecodedMetadata_DoesNotReserveUnpackSize( + bool encodedHeader + ) + { + // Copy produces only the bytes present, despite advertising 16 MiB. + var header = MetadataHeader(encodedHeader, 16 * 1024 * 1024, 1, [1, 0]); + using var stream = CreateArchive(header, [0]); + using var archive = ArchiveFactory.OpenArchive(stream); +#if !LEGACY_DOTNET + var before = GC.GetAllocatedBytesForCurrentThread(); +#endif + Assert.Throws(() => archive.Entries.ToList()); +#if !LEGACY_DOTNET + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 512 * 1024, $"Allocated {allocated} bytes."); +#endif + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_TruncatedDecodedMetadata_AsyncRejects(bool encodedHeader) + { + var header = MetadataHeader(encodedHeader, 16 * 1024 * 1024, 1, [1, 0]); + using var stream = CreateArchive(header, [0]); + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); +#if !LEGACY_DOTNET + var before = GC.GetTotalAllocatedBytes(precise: true); +#endif + await Assert.ThrowsAsync(async () => + { + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) { } + }); +#if !LEGACY_DOTNET + var allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + Assert.True(allocated < 512 * 1024, $"Allocated {allocated} bytes."); +#endif + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_ReportedLzma2MetadataPayload_Rejects(bool async) + { + // The report puts the first packed byte at the start of the next header. + var header = MetadataHeader(false, 16 * 1024 * 1024, 1, [0x21, 0x21, 1, 0x18]); + using var stream = CreateArchive(header); + if (async) + { + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); +#if !LEGACY_DOTNET + var before = GC.GetTotalAllocatedBytes(precise: true); +#endif + await Assert.ThrowsAsync(async () => + { + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) { } + }); +#if !LEGACY_DOTNET + var allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + Assert.True(allocated < 512 * 1024, $"Allocated {allocated} bytes."); +#endif + } + else + { + using var archive = ArchiveFactory.OpenArchive(stream); +#if !LEGACY_DOTNET + var before = GC.GetAllocatedBytesForCurrentThread(); +#endif + Assert.Throws(() => archive.Entries.ToList()); +#if !LEGACY_DOTNET + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 512 * 1024, $"Allocated {allocated} bytes."); +#endif + } + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_BitPackedEmptyFiles_AreValid(bool async) + { + // Nine nameless directories need only two bitmap bytes. A byte-per-file + // bound on the remaining header would incorrectly reject this archive. + using var stream = CreateArchive([1, 5, 9, 14, 2, 0xFF, 0x80, 0, 0]); + if (async) + { + await using var archive = await ArchiveFactory.OpenAsyncArchive( + stream, + cancellationToken: CancellationToken.None + ); + var count = 0; + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) + { + Assert.True(entry.IsDirectory); + count++; + } + Assert.Equal(9, count); + } + else + { + using var archive = ArchiveFactory.OpenArchive(stream); + Assert.Equal(9, archive.Entries.Count()); + Assert.All(archive.Entries, entry => Assert.True(entry.IsDirectory)); + } + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_ExternalFolderCount_UsesExternalBuffer(bool async) + { + // Decode one byte of additional metadata, then reference it as the + // folder-definition buffer. The inline header has ample padding. + var header = Join(MetadataHeader(false, 1, 1, [1, 0]), [4, 7, 11, 2, 1, 0], new byte[128]); + using var stream = CreateArchive(header, [0]); + if (async) + { + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); + await Assert.ThrowsAsync(async () => + { + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) { } + }); + } + else + { + using var archive = ArchiveFactory.OpenArchive(stream); + Assert.Throws(() => archive.Entries.ToList()); + } + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_ExternalFolderDefinition_IsValid(bool async) + { + // Additional metadata contains a Copy folder. Its unpack size remains + // inline; the file's packed byte follows the external folder definition. + var header = Join( + MetadataHeader(false, 3, 3, [1, 0]), + [4, 6, 3, 1, 9, 1, 0, 7, 11, 1, 1, 0, 12, 1, 0, 8, 0, 0, 5, 1, 0, 0] + ); + using var stream = CreateArchive(header, [1, 1, 0, 42]); + if (async) + { + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); + var count = 0; + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) + { + using var entryStream = await entry.OpenEntryStreamAsync(CancellationToken.None); + var data = new byte[1]; + Assert.Equal(1, await entryStream.ReadAsync(data, 0, 1, CancellationToken.None)); + Assert.Equal(42, data[0]); + count++; + } + Assert.Equal(1, count); + } + else + { + using var archive = ArchiveFactory.OpenArchive(stream); + using var entryStream = archive.Entries.Single().OpenEntryStream(); + Assert.Equal(42, entryStream.ReadByte()); + } + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_DecodedMetadataSpanningBuffers_IsValid(bool async) + { + // An unknown archive property forces decoding across several buffers. + var decoded = Join([1, 2, 26], Number(20_000), new byte[20_000], [0, 0]); + var header = MetadataHeader(true, (ulong)decoded.Length, (ulong)decoded.Length, [1, 0]); + using var stream = CreateArchive(header, decoded); + if (async) + { + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) + { + Assert.Fail("Expected an empty archive."); + } + } + else + { + using var archive = ArchiveFactory.OpenArchive(stream); + Assert.Empty(archive.Entries); + } + Assert.True(stream.CanRead); + } + + private static byte[] MetadataHeader( + bool encoded, + ulong unpackSize, + ulong packSize, + byte[] coder + ) => + Join( + encoded ? [23] : [1, 3], + [6, 0, 1, 9], + Number(packSize), + [0, 7, 11, 1, 0, 1], + coder, + [12], + Number(unpackSize), + [0, 0] + ); + + private static byte[] Number(ulong value) + { + using var stream = new MemoryStream(); + stream.WriteEncodedUInt64(value); + return stream.ToArray(); + } + + private static byte[] Join(params byte[][] parts) => parts.SelectMany(part => part).ToArray(); + + private static MemoryStream CreateArchive(byte[] header, byte[]? packedData = null) + { + packedData ??= []; + var crc = Crc32Stream.Compute( + Crc32Stream.DEFAULT_POLYNOMIAL, + Crc32Stream.DEFAULT_SEED, + header + ); + var stream = new MemoryStream(); + SevenZipSignatureHeaderWriter.WriteFinal( + stream, + (ulong)packedData.Length, + (ulong)header.Length, + crc + ); + stream.Write(packedData, 0, packedData.Length); + stream.Write(header, 0, header.Length); + stream.Position = 0; + return stream; + } +}