diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 564bbc97eb7511..437d1109de5e37 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -108,6 +108,8 @@ protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } protected virtual System.Threading.Tasks.ValueTask DisposeAsyncCore() { throw null; } public System.IO.Compression.ZipArchiveEntry? GetEntry(string entryName) { throw null; } + public System.IO.Compression.ZipArchiveEntry? GetNextEntry() { throw null; } + public System.Threading.Tasks.ValueTask GetNextEntryAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } public partial class ZipArchiveEntry { @@ -137,6 +139,7 @@ public enum ZipArchiveMode Read = 0, Create = 1, Update = 2, + ForwardRead = 3, } public enum ZipCompressionMethod { diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index bbb10afbcf342a..8cdfe82e76ec92 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -377,4 +377,28 @@ The decompressed data length does not match the expected value from the archive. + + This operation is not supported in ForwardRead mode. + + + GetNextEntry is only supported when the archive is opened in ForwardRead mode. + + + Stored entries with data descriptors cannot be read in ForwardRead mode because the entry boundary cannot be determined. + + + Encrypted entries with data descriptors cannot be read in ForwardRead mode. + + + The archive stream contains an invalid local file header. + + + Encrypted entries are not supported in ForwardRead mode. + + + This property is not available because the entry uses a data descriptor and the metadata cannot be determined in ForwardRead mode. + + + This entry has no data to read. It may be a directory entry or an empty entry. + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.Async.cs index 20d5f40735cbc2..0b71c1b330b91d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.Async.cs @@ -98,6 +98,9 @@ public static async Task CreateAsync(Stream stream, ZipArchiveMode m // directory up-front await zipArchive.EnsureCentralDirectoryReadAsync(cancellationToken).ConfigureAwait(false); break; + case ZipArchiveMode.ForwardRead: + zipArchive._readEntries = true; + break; case ZipArchiveMode.Update: default: Debug.Assert(mode == ZipArchiveMode.Update); @@ -146,6 +149,8 @@ protected virtual async ValueTask DisposeAsyncCore() switch (_mode) { case ZipArchiveMode.Read: + case ZipArchiveMode.ForwardRead: + await DrainPreviousEntryAsync(default).ConfigureAwait(false); break; case ZipArchiveMode.Create: await WriteFileAsync().ConfigureAwait(false); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 70cd68b810af37..58133ec5cab46e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -7,10 +7,11 @@ using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace System.IO.Compression { @@ -34,6 +35,8 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; + private ZipArchiveEntry? _forwardReadPreviousEntry; + private bool _forwardReadReachedEnd; #if DEBUG_FORCE_ZIP64 public bool _forceZip64; @@ -150,6 +153,9 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? case ZipArchiveMode.Read: ReadEndOfCentralDirectory(); break; + case ZipArchiveMode.ForwardRead: + _readEntries = true; + break; case ZipArchiveMode.Update: default: Debug.Assert(mode == ZipArchiveMode.Update); @@ -231,6 +237,8 @@ public ReadOnlyCollection Entries { if (_mode == ZipArchiveMode.Create) throw new NotSupportedException(SR.EntriesInCreateMode); + if (_mode == ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.ForwardReadOnly); ThrowIfDisposed(); @@ -298,6 +306,8 @@ protected virtual void Dispose(bool disposing) switch (_mode) { case ZipArchiveMode.Read: + case ZipArchiveMode.ForwardRead: + DrainPreviousEntry(); break; case ZipArchiveMode.Create: WriteFile(); @@ -349,12 +359,217 @@ protected virtual void Dispose(bool disposing) if (_mode == ZipArchiveMode.Create) throw new NotSupportedException(SR.EntriesInCreateMode); + if (_mode == ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.ForwardReadOnly); EnsureCentralDirectoryRead(); _entriesDictionary.TryGetValue(entryName, out ZipArchiveEntry? result); return result; } + /// + /// Reads the next entry from the archive when opened in mode. + /// + /// The next in the archive, or if no more entries exist. + /// The archive was not opened in mode. + /// The archive has been disposed. + /// The archive contains invalid data. + public ZipArchiveEntry? GetNextEntry() + { + ThrowIfDisposed(); + if (_mode != ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.GetNextEntryNotInForwardRead); + + if (_forwardReadReachedEnd) + return null; + + DrainPreviousEntry(); + + ZipLocalFileHeader.ForwardReadHeaderData? headerData = + ZipLocalFileHeader.TryReadForForwardRead(_archiveStream, EntryNameAndCommentEncoding); + + if (headerData is null) + { + _forwardReadReachedEnd = true; + return null; + } + + var data = headerData.Value; + + if (data.HasDataDescriptor) + { + if (data.CompressionMethod == ZipCompressionMethod.Stored) + throw new NotSupportedException(SR.ForwardReadStoredDataDescriptorNotSupported); + if (data.IsEncrypted) + throw new NotSupportedException(SR.ForwardReadEncryptedDataDescriptorNotSupported); + } + + Stream? dataStream = BuildForwardReadDataStream(data); + var entry = new ZipArchiveEntry(this, data, dataStream); + _forwardReadPreviousEntry = entry; + + return entry; + } + + /// + /// Asynchronously reads the next entry from the archive when opened in mode. + /// + /// A cancellation token to observe. + /// A representing the next entry, or if no more entries exist. + /// The archive was not opened in mode. + /// The archive has been disposed. + /// The archive contains invalid data. + public ValueTask GetNextEntryAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (_mode != ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.GetNextEntryNotInForwardRead); + + cancellationToken.ThrowIfCancellationRequested(); + + if (_forwardReadReachedEnd) + return new ValueTask((ZipArchiveEntry?)null); + + return GetNextEntryAsyncCore(cancellationToken); + } + + private async ValueTask GetNextEntryAsyncCore(CancellationToken cancellationToken) + { + await DrainPreviousEntryAsync(cancellationToken).ConfigureAwait(false); + + ZipLocalFileHeader.ForwardReadHeaderData? headerData = + await ZipLocalFileHeader.TryReadForForwardReadAsync(_archiveStream, EntryNameAndCommentEncoding, cancellationToken).ConfigureAwait(false); + + if (headerData is null) + { + _forwardReadReachedEnd = true; + return null; + } + + var data = headerData.Value; + + if (data.HasDataDescriptor) + { + if (data.CompressionMethod == ZipCompressionMethod.Stored) + throw new NotSupportedException(SR.ForwardReadStoredDataDescriptorNotSupported); + if (data.IsEncrypted) + throw new NotSupportedException(SR.ForwardReadEncryptedDataDescriptorNotSupported); + } + + Stream? dataStream = BuildForwardReadDataStream(data); + var entry = new ZipArchiveEntry(this, data, dataStream); + _forwardReadPreviousEntry = entry; + + return entry; + } + + private void DrainPreviousEntry() => + DrainPreviousEntryCore(useAsync: false, cancellationToken: default).GetAwaiter().GetResult(); + + private ValueTask DrainPreviousEntryAsync(CancellationToken cancellationToken) => + new ValueTask(DrainPreviousEntryCore(useAsync: true, cancellationToken)); + + private async Task DrainPreviousEntryCore(bool useAsync, CancellationToken cancellationToken) + { + if (_forwardReadPreviousEntry is not { } prev) + return; + + Stream? dataStream = prev.ForwardReadDataStream; + if (dataStream is not null) + { + byte[] buffer = new byte[4096]; + if (useAsync) + { + while (await dataStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false) > 0) { } + } + else + { + while (dataStream.Read(buffer) > 0) { } + } + + var crcResult = (dataStream as CrcValidatingReadStream)?.GetFinalCrcResult(); + + if (useAsync) + await dataStream.DisposeAsync().ConfigureAwait(false); + else + dataStream.Dispose(); + + if (prev.HasDataDescriptor) + { + if (crcResult is not { } actual) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + // Use adaptive parsing: try 32-bit DD first, fall back to Zip64 if + // the parsed values don't match. This handles archives where the writer + // couldn't signal Zip64 in the local header (non-seekable stream writes). + var (crc32, _, uncompressedSize) = useAsync + ? await ZipLocalFileHeader.ReadDataDescriptorAdaptiveAsync( + _archiveStream, actual.Crc32, actual.BytesRead, cancellationToken).ConfigureAwait(false) + : ZipLocalFileHeader.ReadDataDescriptorAdaptive( + _archiveStream, actual.Crc32, actual.BytesRead); + + if (actual.Crc32 != crc32) + throw new InvalidDataException(SR.CrcMismatch); + if (actual.BytesRead != uncompressedSize) + throw new InvalidDataException(SR.UnexpectedStreamLength); + } + } + else if (prev.HasDataDescriptor) + { + if (useAsync) + await ZipLocalFileHeader.ReadDataDescriptorAsync(_archiveStream, prev.IsZip64SizeFields, cancellationToken).ConfigureAwait(false); + else + ZipLocalFileHeader.ReadDataDescriptor(_archiveStream, prev.IsZip64SizeFields); + } + + _forwardReadPreviousEntry = null; + } + + private Stream? BuildForwardReadDataStream(ZipLocalFileHeader.ForwardReadHeaderData data) + { + bool isDirectory = data.FullName.Length > 0 && + (data.FullName[^1] == '/' || data.FullName[^1] == '\\'); + bool isEmptyEntry = !data.HasDataDescriptor && data.CompressedSize == 0 && data.UncompressedSize == 0; + + if (isDirectory || isEmptyEntry) + return null; + + if (data.CompressionMethod != ZipCompressionMethod.Stored && + data.CompressionMethod != ZipCompressionMethod.Deflate && + data.CompressionMethod != ZipCompressionMethod.Deflate64) + { + throw new InvalidDataException(SR.UnsupportedCompression); + } + + if (data.HasDataDescriptor) + { + Stream decompressor = CreateForwardReadDecompressor(_archiveStream, data.CompressionMethod, -1, leaveOpen: true); + + return new CrcValidatingReadStream(decompressor, expectedCrc: 0, expectedLength: long.MaxValue); + } + + if (data.IsEncrypted) + { + return new SubReadStream(_archiveStream, _archiveStream.Position, data.CompressedSize); + } + + // Known size, not encrypted — store lightweight SubReadStream as a bookmark; + // decompressor + CRC wrapper are created lazily in OpenInForwardReadMode. + return new SubReadStream(_archiveStream, _archiveStream.Position, data.CompressedSize); + } + + internal static Stream CreateForwardReadDecompressor(Stream source, ZipCompressionMethod compressionMethod, long uncompressedSize, bool leaveOpen) + { + return compressionMethod switch + { + ZipCompressionMethod.Deflate when leaveOpen => new DeflateStream(source, CompressionMode.Decompress, leaveOpen: true), + ZipCompressionMethod.Deflate => new DeflateStream(source, CompressionMode.Decompress, uncompressedSize), + ZipCompressionMethod.Deflate64 when leaveOpen => new DeflateManagedStream(source, ZipCompressionMethod.Deflate64, -1), + ZipCompressionMethod.Deflate64 => new DeflateManagedStream(source, ZipCompressionMethod.Deflate64, uncompressedSize), + _ => source, + }; + } + internal Stream ArchiveStream => _archiveStream; internal uint NumberOfThisDisk => _numberOfThisDisk; @@ -434,6 +649,8 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre if (_mode == ZipArchiveMode.Read) throw new NotSupportedException(SR.CreateInReadMode); + if (_mode == ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.ForwardReadOnly); ThrowIfDisposed(); @@ -959,6 +1176,10 @@ private static bool ValidateMode(ZipArchiveMode mode, Stream stream) isReadModeAndUnseekable = true; } break; + case ZipArchiveMode.ForwardRead: + if (!stream.CanRead) + throw new ArgumentException(SR.ReadModeCapabilities); + break; case ZipArchiveMode.Update: if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek) throw new ArgumentException(SR.UpdateModeCapabilities); @@ -977,9 +1198,13 @@ private static Stream DecideArchiveStream(ZipArchiveMode mode, Stream stream) { ArgumentNullException.ThrowIfNull(stream); - return mode == ZipArchiveMode.Create && !stream.CanSeek ? - new PositionPreservingWriteOnlyStreamWrapper(stream) : - stream; + if (mode == ZipArchiveMode.Create && !stream.CanSeek) + return new PositionPreservingWriteOnlyStreamWrapper(stream); + + if (mode == ZipArchiveMode.ForwardRead && !stream.CanSeek) + return new ReadAheadStream(stream); + + return stream; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index c3ee984e673f12..547df6cceff82a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -48,6 +48,17 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private readonly CompressionLevel _compressionLevel; + // Forward-read fields: + // _hasDataDescriptor: permanent flag from the local file header indicating sizes/CRC + // are in a trailing data descriptor. When true, Crc32/CompressedLength/Length always throw. + // _isZip64SizeFields: whether the data descriptor uses 64-bit sizes, + // determined by whether the local header had 0xFFFFFFFF size markers. + private Stream? _forwardReadDataStream; + private bool _forwardReadStreamOpened; + private bool _forwardReadNeedsWrapping; + private bool _hasDataDescriptor; + private bool _isZip64SizeFields; + // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) { @@ -160,19 +171,75 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + // Initializes a ZipArchiveEntry instance for forward-read mode from parsed local file header data. + internal ZipArchiveEntry(ZipArchive archive, ZipLocalFileHeader.ForwardReadHeaderData headerData, Stream? dataStream) + { + _archive = archive; + _originallyInArchive = true; + _hasDataDescriptor = headerData.HasDataDescriptor; + _isZip64SizeFields = headerData.IsZip64SizeFields; + + _diskNumberStart = 0; + _versionMadeByPlatform = CurrentZipPlatform; + _versionMadeBySpecification = (ZipVersionNeededValues)headerData.VersionNeeded; + _versionToExtract = (ZipVersionNeededValues)headerData.VersionNeeded; + _generalPurposeBitFlag = (BitFlagValues)headerData.GeneralPurposeBitFlags; + _isEncrypted = headerData.IsEncrypted; + _storedCompressionMethod = headerData.CompressionMethod; + _lastModified = headerData.LastModified; + _compressedSize = headerData.CompressedSize; + _uncompressedSize = headerData.UncompressedSize; + _crc32 = headerData.Crc32; + _offsetOfLocalHeader = 0; + _storedOffsetOfCompressedData = null; + _externalFileAttr = 0; + + _compressedBytes = null; + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + _outstandingWriteStream = null; + + _storedEntryNameBytes = headerData.FilenameBytes.ToArray(); + _storedEntryName = headerData.FullName; + + _cdUnknownExtraFields = null; + _lhUnknownExtraFields = null; + + _fileComment = Array.Empty(); + _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, headerData.CompressionMethod); + _forwardReadDataStream = dataStream; + _forwardReadNeedsWrapping = dataStream is not null && !headerData.HasDataDescriptor && !headerData.IsEncrypted; + Changes = ZipArchive.ChangeState.Unchanged; + } + /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. /// public ZipArchive Archive => _archive; [CLSCompliant(false)] - public uint Crc32 => _crc32; + public uint Crc32 + { + get + { + if (_hasDataDescriptor && _archive.Mode == ZipArchiveMode.ForwardRead) + throw new InvalidOperationException(SR.ForwardReadMetadataNotYetAvailable); + return _crc32; + } + } /// /// Gets a value that indicates whether the entry is encrypted. /// public bool IsEncrypted => _isEncrypted; + internal Stream? ForwardReadDataStream => _forwardReadDataStream; + + internal bool HasDataDescriptor => _hasDataDescriptor; + + internal bool IsZip64SizeFields => _isZip64SizeFields; + /// /// Gets the compression method used to compress the entry. /// @@ -197,6 +264,8 @@ public long CompressedLength { get { + if (_hasDataDescriptor && _archive.Mode == ZipArchiveMode.ForwardRead) + throw new InvalidOperationException(SR.ForwardReadMetadataNotYetAvailable); if (_everOpenedForWrite) throw new InvalidOperationException(SR.LengthAfterWrite); return _compressedSize; @@ -212,6 +281,8 @@ public int ExternalAttributes set { ThrowIfInvalidArchive(); + if (_archive.Mode == ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.ForwardReadOnly); _externalFileAttr = (uint)value; Changes |= ZipArchive.ChangeState.FixedLengthMetadata; } @@ -230,6 +301,8 @@ public string Comment get => DecodeEntryString(_fileComment); set { + if (_archive.Mode == ZipArchiveMode.ForwardRead) + throw new NotSupportedException(SR.ForwardReadOnly); _fileComment = ZipHelper.GetEncodedTruncatedBytesFromString(value, _archive.EntryNameAndCommentEncoding, ushort.MaxValue, out bool isUTF8); if (isUTF8) @@ -293,7 +366,7 @@ public DateTimeOffset LastWriteTime set { ThrowIfInvalidArchive(); - if (_archive.Mode == ZipArchiveMode.Read) + if (_archive.Mode is ZipArchiveMode.Read or ZipArchiveMode.ForwardRead) throw new NotSupportedException(SR.ReadOnlyArchive); if (_archive.Mode == ZipArchiveMode.Create && _everOpenedForWrite) throw new IOException(SR.FrozenAfterWrite); @@ -313,6 +386,8 @@ public long Length { get { + if (_hasDataDescriptor && _archive.Mode == ZipArchiveMode.ForwardRead) + throw new InvalidOperationException(SR.ForwardReadMetadataNotYetAvailable); if (_everOpenedForWrite) throw new InvalidOperationException(SR.LengthAfterWrite); return _uncompressedSize; @@ -371,6 +446,8 @@ public Stream Open() return OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: return OpenInWriteMode(); + case ZipArchiveMode.ForwardRead: + return OpenInForwardReadMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -416,6 +493,11 @@ public Stream Open(FileAccess access) throw new InvalidOperationException(SR.CannotBeReadInCreateMode); return OpenInWriteMode(); + case ZipArchiveMode.ForwardRead: + if (access != FileAccess.Read) + throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + return OpenInForwardReadMode(); + case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -846,6 +928,32 @@ private CrcValidatingReadStream OpenInReadModeGetDataCompressor(long offsetOfCom return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); } + private WrappedStream OpenInForwardReadMode() + { + if (_isEncrypted) + throw new NotSupportedException(SR.ForwardReadEncryptedNotSupported); + if (_forwardReadDataStream is null) + throw new InvalidOperationException(SR.ForwardReadNoDataStream); + if (_forwardReadStreamOpened) + throw new IOException(SR.ForwardReadOnly); + + // For known-size entries, the data stream is a raw bounded stream — + // lazily wrap it with decompressor + CRC validation on first Open(). + if (_forwardReadNeedsWrapping) + { + Stream decompressor = ZipArchive.CreateForwardReadDecompressor( + _forwardReadDataStream, _storedCompressionMethod, _uncompressedSize, leaveOpen: false); + _forwardReadDataStream = new CrcValidatingReadStream(decompressor, _crc32, _uncompressedSize); + _forwardReadNeedsWrapping = false; + } + + _forwardReadStreamOpened = true; + + // Wrap so user disposal does not close our internal data stream. + // DrainPreviousEntry will drain and dispose _forwardReadDataStream itself. + return new WrappedStream(_forwardReadDataStream, closeBaseStream: false); + } + private WrappedStream OpenInWriteMode() { if (_everOpenedForWrite) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveMode.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveMode.cs index 9119fc1a9aba90..eac6d5972dd813 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveMode.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveMode.cs @@ -26,6 +26,12 @@ public enum ZipArchiveMode /// The underlying file or stream must be readable, writable and seekable. /// No data will be written to the underlying file or stream until the archive is disposed. /// - Update + Update, + /// + /// Only forward-only sequential reading of entries is permitted using . + /// Entries are read from local file headers instead of the central directory, enabling reading from non-seekable streams + /// without buffering the entire archive. The underlying stream must be readable but need not be seekable. + /// + ForwardRead = 3 } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs index fea75ba16db328..f23fb23c2c7cb1 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -156,6 +158,153 @@ public static async Task TrySkipBlockAsync(Stream stream, CancellationToke bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } + + /// + /// Async variant of . + /// + internal static async ValueTask TryReadForForwardReadAsync(Stream stream, Encoding? entryNameEncoding, CancellationToken cancellationToken) + { + byte[] header = new byte[SizeOfLocalHeader]; + int bytesRead = await stream.ReadAtLeastAsync(header, SizeOfLocalHeader, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + return null; + if (bytesRead < SizeOfLocalHeader) + { + if (bytesRead >= FieldLengths.Signature && IsEndOfEntriesSignature(BinaryPrimitives.ReadUInt32LittleEndian(header))) + return null; + throw new InvalidDataException(SR.ForwardReadInvalidLocalFileHeader); + } + + if (!header.AsSpan(0, FieldLengths.Signature).SequenceEqual(SignatureConstantBytes)) + { + uint sig = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (IsEndOfEntriesSignature(sig)) + return null; + throw new InvalidDataException(SR.ForwardReadInvalidLocalFileHeader); + } + + return await ParseForwardReadHeaderAsync(header, stream, entryNameEncoding, cancellationToken).ConfigureAwait(false); + + static async ValueTask ParseForwardReadHeaderAsync(byte[] header, Stream stream, Encoding? entryNameEncoding, CancellationToken cancellationToken) + { + ushort versionNeeded = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(FieldLocations.VersionNeededToExtract)); + ushort generalPurposeBitFlags = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(FieldLocations.GeneralPurposeBitFlags)); + ushort compressionMethodValue = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(FieldLocations.CompressionMethod)); + uint lastModified = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(FieldLocations.LastModified)); + uint crc32 = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(FieldLocations.Crc32)); + uint compressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(FieldLocations.CompressedSize)); + uint uncompressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(FieldLocations.UncompressedSize)); + ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(FieldLocations.FilenameLength)); + ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(FieldLocations.ExtraFieldLength)); + + byte[] filenameBytes = new byte[filenameLength]; + if (filenameLength > 0) + await stream.ReadExactlyAsync(filenameBytes, cancellationToken).ConfigureAwait(false); + + byte[] extraFieldBytes = new byte[extraFieldLength]; + if (extraFieldLength > 0) + await stream.ReadExactlyAsync(extraFieldBytes, cancellationToken).ConfigureAwait(false); + + long compressedSize = compressedSizeSmall; + long uncompressedSize = uncompressedSizeSmall; + bool isZip64SizeFields = false; + + if (compressedSizeSmall == ZipHelper.Mask32Bit || uncompressedSizeSmall == ZipHelper.Mask32Bit) + { + isZip64SizeFields = true; + Zip64ExtraField zip64 = Zip64ExtraField.GetJustZip64Block(extraFieldBytes, + readUncompressedSize: uncompressedSizeSmall == ZipHelper.Mask32Bit, + readCompressedSize: compressedSizeSmall == ZipHelper.Mask32Bit, + readLocalHeaderOffset: false, + readStartDiskNumber: false); + + if (zip64.UncompressedSize.HasValue) + uncompressedSize = zip64.UncompressedSize.Value; + if (zip64.CompressedSize.HasValue) + compressedSize = zip64.CompressedSize.Value; + } + + bool isUtf8 = (generalPurposeBitFlags & (ushort)ZipArchiveEntry.BitFlagValues.UnicodeFileNameAndComment) != 0; + Encoding nameEncoding = isUtf8 ? Encoding.UTF8 : (entryNameEncoding ?? Encoding.UTF8); + string fullName = nameEncoding.GetString(filenameBytes); + + DateTimeOffset lastModifiedDto = new DateTimeOffset(ZipHelper.DosTimeToDateTime(lastModified)); + + return new ForwardReadHeaderData( + versionNeeded, generalPurposeBitFlags, (ZipCompressionMethod)compressionMethodValue, + lastModifiedDto, crc32, compressedSize, uncompressedSize, + fullName, filenameBytes, isZip64SizeFields); + } + } + + /// + /// Async variant of . + /// + internal static async ValueTask<(uint Crc32, long CompressedSize, long UncompressedSize)> ReadDataDescriptorAsync(Stream stream, bool isZip64, CancellationToken cancellationToken) + { + byte[] firstFour = new byte[4]; + await stream.ReadExactlyAsync(firstFour, cancellationToken).ConfigureAwait(false); + + uint firstWord = BinaryPrimitives.ReadUInt32LittleEndian(firstFour); + bool hasSignature = firstWord == 0x08074B50; + + int remainingSize = (hasSignature ? 4 : 0) + (isZip64 ? 16 : 8); + byte[] remaining = new byte[remainingSize]; + await stream.ReadExactlyAsync(remaining, cancellationToken).ConfigureAwait(false); + + int pos = 0; + uint crc32; + if (hasSignature) + { + crc32 = BinaryPrimitives.ReadUInt32LittleEndian(remaining.AsSpan(pos)); + pos += 4; + } + else + { + crc32 = firstWord; + } + + long compressedSize, uncompressedSize; + if (isZip64) + { + compressedSize = BinaryPrimitives.ReadInt64LittleEndian(remaining.AsSpan(pos)); + uncompressedSize = BinaryPrimitives.ReadInt64LittleEndian(remaining.AsSpan(pos + 8)); + } + else + { + compressedSize = BinaryPrimitives.ReadUInt32LittleEndian(remaining.AsSpan(pos)); + uncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(remaining.AsSpan(pos + 4)); + } + + return (crc32, compressedSize, uncompressedSize); + } + + /// + /// Async variant of . + /// + internal static async ValueTask<(uint Crc32, long CompressedSize, long UncompressedSize)> ReadDataDescriptorAdaptiveAsync( + Stream stream, uint knownCrc32, long knownUncompressedSize, CancellationToken cancellationToken) + { + byte[] firstFour = new byte[4]; + await stream.ReadExactlyAsync(firstFour, cancellationToken).ConfigureAwait(false); + + uint firstWord = BinaryPrimitives.ReadUInt32LittleEndian(firstFour); + bool hasSignature = firstWord == 0x08074B50; + + int smallSize = (hasSignature ? 4 : 0) + 8; + byte[] buf = new byte[20]; + await stream.ReadExactlyAsync(buf.AsMemory(0, smallSize), cancellationToken).ConfigureAwait(false); + + var small = ParseDataDescriptor(buf.AsSpan(0, smallSize), hasSignature, isZip64: false, firstWord); + if (small.Crc32 == knownCrc32 && small.UncompressedSize == knownUncompressedSize) + return small; + + await stream.ReadExactlyAsync(buf.AsMemory(smallSize, 8), cancellationToken).ConfigureAwait(false); + int fullSize = smallSize + 8; + + return ParseDataDescriptor(buf.AsSpan(0, fullSize), hasSignature, isZip64: true, firstWord); + } } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 5e70cf29fc5eaa..2d653aa317a745 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -670,6 +671,204 @@ public static bool TrySkipBlock(Stream stream) bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } + + /// + /// Parsed data from a local file header for forward-read mode. + /// + internal readonly struct ForwardReadHeaderData( + ushort versionNeeded, ushort generalPurposeBitFlags, ZipCompressionMethod compressionMethod, + DateTimeOffset lastModified, uint crc32, long compressedSize, long uncompressedSize, + string fullName, ReadOnlyMemory filenameBytes, bool isZip64SizeFields) + { + internal readonly ushort VersionNeeded = versionNeeded; + internal readonly ushort GeneralPurposeBitFlags = generalPurposeBitFlags; + internal readonly ZipCompressionMethod CompressionMethod = compressionMethod; + internal readonly DateTimeOffset LastModified = lastModified; + internal readonly uint Crc32 = crc32; + internal readonly long CompressedSize = compressedSize; + internal readonly long UncompressedSize = uncompressedSize; + internal readonly string FullName = fullName; + internal readonly ReadOnlyMemory FilenameBytes = filenameBytes; + internal readonly bool IsZip64SizeFields = isZip64SizeFields; + + internal bool HasDataDescriptor => (GeneralPurposeBitFlags & 0x0008) != 0; + internal bool IsEncrypted => (GeneralPurposeBitFlags & 0x0001) != 0; + } + + /// + /// Returns true if the given signature indicates the end of local file entries + /// (central directory, EOCD, or Zip64 EOCD). + /// + internal static bool IsEndOfEntriesSignature(uint sig) => + sig == 0x02014B50 // Central directory + || sig == 0x06054B50 // EOCD + || sig == 0x06064B50; // Zip64 EOCD + + /// + /// Tries to read a local file header for forward-read mode. + /// Returns null if EOF is reached or a non-local-header signature is encountered. + /// + internal static ForwardReadHeaderData? TryReadForForwardRead(Stream stream, Encoding? entryNameEncoding) + { + Span header = stackalloc byte[SizeOfLocalHeader]; + int bytesRead = stream.ReadAtLeast(header, SizeOfLocalHeader, throwOnEndOfStream: false); + + if (bytesRead == 0) + return null; + if (bytesRead < SizeOfLocalHeader) + { + if (bytesRead >= FieldLengths.Signature && IsEndOfEntriesSignature(BinaryPrimitives.ReadUInt32LittleEndian(header))) + return null; + throw new InvalidDataException(SR.ForwardReadInvalidLocalFileHeader); + } + + if (!header[..FieldLengths.Signature].SequenceEqual(SignatureConstantBytes)) + { + uint sig = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (IsEndOfEntriesSignature(sig)) + return null; + throw new InvalidDataException(SR.ForwardReadInvalidLocalFileHeader); + } + + return ParseForwardReadHeader(header, stream, entryNameEncoding); + + static ForwardReadHeaderData ParseForwardReadHeader(ReadOnlySpan header, Stream stream, Encoding? entryNameEncoding) + { + ushort versionNeeded = BinaryPrimitives.ReadUInt16LittleEndian(header[FieldLocations.VersionNeededToExtract..]); + ushort generalPurposeBitFlags = BinaryPrimitives.ReadUInt16LittleEndian(header[FieldLocations.GeneralPurposeBitFlags..]); + ushort compressionMethodValue = BinaryPrimitives.ReadUInt16LittleEndian(header[FieldLocations.CompressionMethod..]); + uint lastModified = BinaryPrimitives.ReadUInt32LittleEndian(header[FieldLocations.LastModified..]); + uint crc32 = BinaryPrimitives.ReadUInt32LittleEndian(header[FieldLocations.Crc32..]); + uint compressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(header[FieldLocations.CompressedSize..]); + uint uncompressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(header[FieldLocations.UncompressedSize..]); + ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[FieldLocations.FilenameLength..]); + ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(header[FieldLocations.ExtraFieldLength..]); + + byte[] filenameBytes = new byte[filenameLength]; + if (filenameLength > 0) + stream.ReadExactly(filenameBytes); + + byte[] extraFieldBytes = new byte[extraFieldLength]; + if (extraFieldLength > 0) + stream.ReadExactly(extraFieldBytes); + + long compressedSize = compressedSizeSmall; + long uncompressedSize = uncompressedSizeSmall; + bool isZip64SizeFields = false; + + if (compressedSizeSmall == ZipHelper.Mask32Bit || uncompressedSizeSmall == ZipHelper.Mask32Bit) + { + isZip64SizeFields = true; + Zip64ExtraField zip64 = Zip64ExtraField.GetJustZip64Block(extraFieldBytes, + readUncompressedSize: uncompressedSizeSmall == ZipHelper.Mask32Bit, + readCompressedSize: compressedSizeSmall == ZipHelper.Mask32Bit, + readLocalHeaderOffset: false, + readStartDiskNumber: false); + + if (zip64.UncompressedSize.HasValue) + uncompressedSize = zip64.UncompressedSize.Value; + if (zip64.CompressedSize.HasValue) + compressedSize = zip64.CompressedSize.Value; + } + + bool isUtf8 = (generalPurposeBitFlags & (ushort)ZipArchiveEntry.BitFlagValues.UnicodeFileNameAndComment) != 0; + Encoding nameEncoding = isUtf8 ? Encoding.UTF8 : (entryNameEncoding ?? Encoding.UTF8); + string fullName = nameEncoding.GetString(filenameBytes); + + DateTimeOffset lastModifiedDto = new DateTimeOffset(ZipHelper.DosTimeToDateTime(lastModified)); + + return new ForwardReadHeaderData( + versionNeeded, generalPurposeBitFlags, (ZipCompressionMethod)compressionMethodValue, + lastModifiedDto, crc32, compressedSize, uncompressedSize, + fullName, filenameBytes, isZip64SizeFields); + } + } + + /// + /// Reads a data descriptor using signature-first parsing. No seek operations. + /// + internal static (uint Crc32, long CompressedSize, long UncompressedSize) ReadDataDescriptor(Stream stream, bool isZip64) + { + Span firstFour = stackalloc byte[4]; + stream.ReadExactly(firstFour); + + uint firstWord = BinaryPrimitives.ReadUInt32LittleEndian(firstFour); + bool hasSignature = firstWord == 0x08074B50; + + int remainingSize = (hasSignature ? 4 : 0) + (isZip64 ? 16 : 8); + Span remaining = stackalloc byte[20]; + stream.ReadExactly(remaining[..remainingSize]); + + return ParseDataDescriptor(remaining[..remainingSize], hasSignature, isZip64, firstWord); + } + + /// + /// Reads a data descriptor whose size (32-bit or Zip64) is unknown. + /// Parses as 32-bit first; if the CRC and uncompressed size don't match + /// the expected values, reads additional bytes and re-parses as Zip64. + /// + /// + /// Some writers (including .NET on non-seekable streams) emit a Zip64 + /// data descriptor without setting any Zip64 indicator in the local + /// file header, because the final sizes are not known at header-write + /// time. The known values from the decompressor let us detect the + /// correct layout without relying on header signals. + /// + internal static (uint Crc32, long CompressedSize, long UncompressedSize) ReadDataDescriptorAdaptive( + Stream stream, uint knownCrc32, long knownUncompressedSize) + { + Span firstFour = stackalloc byte[4]; + stream.ReadExactly(firstFour); + + uint firstWord = BinaryPrimitives.ReadUInt32LittleEndian(firstFour); + bool hasSignature = firstWord == 0x08074B50; + + // Read enough for the 32-bit layout: CRC(4) + CompSize(4) + UncompSize(4), + // plus CRC(4) again if the signature consumed firstWord. + int smallSize = (hasSignature ? 4 : 0) + 8; + Span buf = stackalloc byte[20]; + stream.ReadExactly(buf[..smallSize]); + + var small = ParseDataDescriptor(buf[..smallSize], hasSignature, isZip64: false, firstWord); + if (small.Crc32 == knownCrc32 && small.UncompressedSize == knownUncompressedSize) + return small; + + // 32-bit interpretation didn't match — read 8 more bytes for the Zip64 layout. + stream.ReadExactly(buf.Slice(smallSize, 8)); + int fullSize = smallSize + 8; + + return ParseDataDescriptor(buf[..fullSize], hasSignature, isZip64: true, firstWord); + } + + private static (uint Crc32, long CompressedSize, long UncompressedSize) ParseDataDescriptor( + ReadOnlySpan remaining, bool hasSignature, bool isZip64, uint firstWord) + { + int pos = 0; + uint crc32; + if (hasSignature) + { + crc32 = BinaryPrimitives.ReadUInt32LittleEndian(remaining[pos..]); + pos += 4; + } + else + { + crc32 = firstWord; + } + + long compressedSize, uncompressedSize; + if (isZip64) + { + compressedSize = BinaryPrimitives.ReadInt64LittleEndian(remaining[pos..]); + uncompressedSize = BinaryPrimitives.ReadInt64LittleEndian(remaining[(pos + 8)..]); + } + else + { + compressedSize = BinaryPrimitives.ReadUInt32LittleEndian(remaining[pos..]); + uncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(remaining[(pos + 4)..]); + } + + return (crc32, compressedSize, uncompressedSize); + } } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index ea2fc10ec55699..13a340f1cc5929 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -735,6 +735,13 @@ public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expecte _runningCrc = 0; } + internal (uint Crc32, long BytesRead)? GetFinalCrcResult() + { + if (_crcAbandoned) + return null; + return (_runningCrc, _totalBytesRead); + } + public override bool CanRead => !_isDisposed && _baseStream.CanRead; public override bool CanSeek => !_isDisposed && _baseStream.CanSeek; public override bool CanWrite => false; @@ -967,4 +974,238 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } + + internal sealed class ReadAheadStream : Stream + { + private readonly Stream _baseStream; + private readonly byte[] _history; + private int _historyCount; + private byte[]? _pushback; + private int _pushbackOffset; + private int _pushbackCount; + private long _position; + private bool _isDisposed; + + public ReadAheadStream(Stream baseStream, int historyCapacity = 8192) + { + _baseStream = baseStream; + _history = new byte[historyCapacity]; + } + + public override bool CanRead => !_isDisposed && _baseStream.CanRead; + // Must report true: DeflateStream checks CanSeek to rewind unconsumed bytes + // after decompression finishes, which is critical for data descriptor entries. + public override bool CanSeek => !_isDisposed; + public override bool CanWrite => false; + + public override long Length + { + get + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + } + + public override long Position + { + get + { + ThrowIfDisposed(); + return _position; + } + set + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + ThrowIfDisposed(); + + int totalRead = 0; + + if (_pushbackCount > 0) + { + int fromPushback = Math.Min(buffer.Length, _pushbackCount); + _pushback.AsSpan(_pushbackOffset, fromPushback).CopyTo(buffer); + RecordHistory(buffer.Slice(0, fromPushback)); + _pushbackOffset += fromPushback; + _pushbackCount -= fromPushback; + totalRead += fromPushback; + buffer = buffer.Slice(fromPushback); + + if (_pushbackCount == 0) + { + _pushback = null; + } + } + + if (buffer.Length > 0) + { + int fromBase = _baseStream.Read(buffer); + if (fromBase > 0) + { + RecordHistory(buffer.Slice(0, fromBase)); + totalRead += fromBase; + } + } + + _position += totalRead; + return totalRead; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + int totalRead = 0; + + if (_pushbackCount > 0) + { + int fromPushback = Math.Min(buffer.Length, _pushbackCount); + _pushback.AsSpan(_pushbackOffset, fromPushback).CopyTo(buffer.Span); + RecordHistory(buffer.Span.Slice(0, fromPushback)); + _pushbackOffset += fromPushback; + _pushbackCount -= fromPushback; + totalRead += fromPushback; + buffer = buffer.Slice(fromPushback); + + if (_pushbackCount == 0) + { + _pushback = null; + } + } + + if (buffer.Length > 0) + { + int fromBase = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (fromBase > 0) + { + RecordHistory(buffer.Span.Slice(0, fromBase)); + totalRead += fromBase; + } + } + + _position += totalRead; + return totalRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + + if (origin is SeekOrigin.Current && offset < 0) + { + int rewindBytes = checked((int)(-offset)); + + if (rewindBytes > _historyCount) + { + throw new IOException(SR.IO_SeekBeforeBegin); + } + + int existingPushback = _pushbackCount; + byte[] newPushback = new byte[rewindBytes + existingPushback]; + Array.Copy(_history, _historyCount - rewindBytes, newPushback, 0, rewindBytes); + + if (existingPushback > 0) + { + Array.Copy(_pushback!, _pushbackOffset, newPushback, rewindBytes, existingPushback); + } + + _pushback = newPushback; + _pushbackOffset = 0; + _pushbackCount = newPushback.Length; + _historyCount -= rewindBytes; + _position -= rewindBytes; + + return _position; + } + + throw new NotSupportedException(); + } + + private void RecordHistory(ReadOnlySpan data) + { + if (data.Length >= _history.Length) + { + data.Slice(data.Length - _history.Length).CopyTo(_history); + _historyCount = _history.Length; + } + else if (_historyCount + data.Length <= _history.Length) + { + data.CopyTo(_history.AsSpan(_historyCount)); + _historyCount += data.Length; + } + else + { + int toKeep = _history.Length - data.Length; + Array.Copy(_history, _historyCount - toKeep, _history, 0, toKeep); + data.CopyTo(_history.AsSpan(toKeep)); + _historyCount = _history.Length; + } + } + + public override void Flush() + { + ThrowIfDisposed(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return Task.CompletedTask; + } + + public override void SetLength(long value) + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + _baseStream.Dispose(); + _isDisposed = true; + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + _isDisposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj b/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj index 284ccb348c15fb..896df4da141c23 100644 --- a/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj +++ b/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ForwardReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ForwardReadTests.cs new file mode 100644 index 00000000000000..71fff8019f9eb3 --- /dev/null +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ForwardReadTests.cs @@ -0,0 +1,357 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + public class zip_ForwardReadTests : ZipFileTestBase + { + private static readonly byte[] s_smallContent = "Hello, small world!"u8.ToArray(); + private static readonly byte[] s_mediumContent = new byte[8192]; + private static readonly byte[] s_largeContent = new byte[65536]; + + static zip_ForwardReadTests() + { + Random rng = new(42); + rng.NextBytes(s_mediumContent); + rng.NextBytes(s_largeContent); + } + + // ── Core reading scenarios ────────────────────────────────────────── + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task NonSeekableStream_ConsumeSkipConsume_ReadsCorrectly(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + byte[][] expected = [s_smallContent, s_mediumContent, s_largeContent]; + + using MemoryStream archiveStream = new(zipBytes); + using WrappedStream nonSeekable = new(archiveStream, canRead: true, canWrite: false, canSeek: false, null); + using ZipArchive archive = new(nonSeekable, ZipArchiveMode.ForwardRead); + + // Consume first entry fully + ZipArchiveEntry? first = await GetNextEntry(archive, async); + Assert.NotNull(first); + using (Stream ds = first.Open()) + { + byte[] data = await ReadStreamFully(ds, async); + Assert.Equal(expected[0], data); + } + + // Skip second entry (don't open/read) + ZipArchiveEntry? second = await GetNextEntry(archive, async); + Assert.NotNull(second); + Assert.Equal("medium.bin", second.FullName); + + // Consume third entry fully + ZipArchiveEntry? third = await GetNextEntry(archive, async); + Assert.NotNull(third); + using (Stream ds = third.Open()) + { + byte[] data = await ReadStreamFully(ds, async); + Assert.Equal(expected[2], data); + } + + // End of archive + Assert.Null(await GetNextEntry(archive, async)); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task SeekableStream_StoredEntries_ReadsCorrectly(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.NoCompression, seekable: true); + byte[][] expected = [s_smallContent, s_mediumContent, s_largeContent]; + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + for (int i = 0; i < expected.Length; i++) + { + ZipArchiveEntry? entry = await GetNextEntry(archive, async); + Assert.NotNull(entry); + Assert.Equal(ZipCompressionMethod.Stored, entry.CompressionMethod); + + using Stream ds = entry.Open(); + Assert.Equal(expected[i], await ReadStreamFully(ds, async)); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task PartialRead_ThenAdvance_ReadsNextEntryCorrectly(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + ZipArchiveEntry? first = await GetNextEntry(archive, async); + Assert.NotNull(first); + + // Only read a few bytes, don't finish + using (Stream ds = first.Open()) + { + byte[] partial = new byte[3]; + await ReadStream(ds, partial, async); + } + + // Next entry should still be readable + ZipArchiveEntry? second = await GetNextEntry(archive, async); + Assert.NotNull(second); + Assert.Equal("medium.bin", second.FullName); + + using Stream ds2 = second.Open(); + Assert.Equal(s_mediumContent, await ReadStreamFully(ds2, async)); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EmptyAndDirectoryEntries_HandleCorrectly(bool async) + { + using MemoryStream ms = new(); + using (ZipArchive create = new(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + create.CreateEntry("mydir/"); + create.CreateEntry("empty.txt"); + } + + ms.Position = 0; + using ZipArchive archive = new(ms, ZipArchiveMode.ForwardRead); + + ZipArchiveEntry? dir = await GetNextEntry(archive, async); + Assert.NotNull(dir); + Assert.Equal("mydir/", dir.FullName); + + ZipArchiveEntry? empty = await GetNextEntry(archive, async); + Assert.NotNull(empty); + Assert.Equal("empty.txt", empty.FullName); + Assert.Equal(0, empty.CompressedLength); + + Assert.Null(await GetNextEntry(archive, async)); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EmptyArchive_ReturnsNull(bool async) + { + using MemoryStream ms = new(); + using (new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { } + + ms.Position = 0; + using ZipArchive archive = new(ms, ZipArchiveMode.ForwardRead); + + Assert.Null(await GetNextEntry(archive, async)); + } + + // ── Unsupported feature guards ────────────────────────────────────── + + [Fact] + public void UnsupportedOperations_Throw() + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + Assert.Throws(() => archive.Entries); + Assert.Throws(() => archive.GetEntry("small.txt")); + Assert.Throws(() => archive.CreateEntry("new.txt")); + } + + [Fact] + public void GetNextEntry_NotInForwardReadMode_Throws() + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read); + + Assert.Throws(() => archive.GetNextEntry()); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task StoredWithDataDescriptor_Throws(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.NoCompression, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using WrappedStream nonSeekable = new(archiveStream, canRead: true, canWrite: false, canSeek: false, null); + using ZipArchive archive = new(nonSeekable, ZipArchiveMode.ForwardRead); + + if (async) + await Assert.ThrowsAsync(() => archive.GetNextEntryAsync().AsTask()); + else + Assert.Throws(() => archive.GetNextEntry()); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_MetadataAccessible_OpenThrows(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + // Set encryption bit in first entry's local file header (offset 6) + zipBytes[6] |= 0x01; + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + ZipArchiveEntry? entry = await GetNextEntry(archive, async); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + Assert.Equal("small.txt", entry.FullName); + + Assert.Throws(() => entry.Open()); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task DataDescriptorEntry_SizeAndCrcProperties_AlwaysThrow(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + ZipArchiveEntry? entry = await GetNextEntry(archive, async); + Assert.NotNull(entry); + + // Non-size properties work + Assert.Equal("small.txt", entry.FullName); + _ = entry.LastWriteTime; + Assert.Equal(ZipCompressionMethod.Deflate, entry.CompressionMethod); + + // Size/CRC properties throw — permanently, even after reading + Assert.Throws(() => entry.Crc32); + Assert.Throws(() => entry.CompressedLength); + Assert.Throws(() => entry.Length); + + using (Stream ds = entry.Open()) + await ReadStreamFully(ds, async); + + await GetNextEntry(archive, async); // drains data descriptor + + Assert.Throws(() => entry.Crc32); + Assert.Throws(() => entry.CompressedLength); + Assert.Throws(() => entry.Length); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task KnownSizeEntry_SizeAndCrcProperties_Accessible(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead); + + ZipArchiveEntry? entry = await GetNextEntry(archive, async); + Assert.NotNull(entry); + + _ = entry.Crc32; + Assert.True(entry.CompressedLength > 0); + Assert.Equal(s_smallContent.Length, entry.Length); + } + + // ── Dispose / lifecycle ───────────────────────────────────────────── + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task GetNextEntry_AfterDispose_Throws(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead, leaveOpen: true); + archive.Dispose(); + + if (async) + await Assert.ThrowsAsync(() => archive.GetNextEntryAsync().AsTask()); + else + Assert.Throws(() => archive.GetNextEntry()); + } + + [Fact] + public void LeaveOpen_DoesNotDisposeStream() + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + new ZipArchive(archiveStream, ZipArchiveMode.ForwardRead, leaveOpen: true).Dispose(); + + Assert.True(archiveStream.CanRead); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Dispose_WithPendingDataDescriptor_DoesNotThrow(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + ZipArchive archive = new(archiveStream, ZipArchiveMode.ForwardRead, leaveOpen: true); + + // Read last entry, then dispose — Dispose must drain data descriptor + ZipArchiveEntry? entry; + do { entry = await GetNextEntry(archive, async); } + while (entry is not null && entry.FullName != "large.bin"); + + Assert.NotNull(entry); + using (Stream ds = entry.Open()) + await ReadStreamFully(ds, async); + + if (async) + await archive.DisposeAsync(); + else + archive.Dispose(); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static async ValueTask GetNextEntry(ZipArchive archive, bool async) => + async ? await archive.GetNextEntryAsync() : archive.GetNextEntry(); + + private static async ValueTask ReadStream(Stream stream, byte[] buffer, bool async) => + async ? await stream.ReadAsync(buffer) : stream.Read(buffer); + + private static byte[] CreateZipWithEntries(CompressionLevel compressionLevel, bool seekable) + { + MemoryStream ms = new(); + + Stream writeStream = seekable + ? ms + : new WrappedStream(ms, canRead: true, canWrite: true, canSeek: false, null); + + using (ZipArchive archive = new(writeStream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "small.txt", s_smallContent, compressionLevel); + AddEntry(archive, "medium.bin", s_mediumContent, compressionLevel); + AddEntry(archive, "large.bin", s_largeContent, compressionLevel); + } + + return ms.ToArray(); + + static void AddEntry(ZipArchive archive, string name, byte[] contents, CompressionLevel level) + { + ZipArchiveEntry entry = archive.CreateEntry(name, level); + using Stream stream = entry.Open(); + stream.Write(contents); + } + } + + private static async Task ReadStreamFully(Stream stream, bool async) + { + using MemoryStream result = new(); + if (async) + await stream.CopyToAsync(result); + else + stream.CopyTo(result); + return result.ToArray(); + } + } +}