From 527c6983106dbe18ff62755dc025428d67605d8b Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 20 Oct 2025 15:38:32 +0200 Subject: [PATCH 01/83] working zipcrypto, hardcoded password --- .../tests/ZipFile.Extract.cs | 44 ++++++ .../src/System.IO.Compression.csproj | 27 ++-- .../System/IO/Compression/ZipArchiveEntry.cs | 44 +++++- .../Compression/ZipCryptoDecryptionStream.cs | 143 ++++++++++++++++++ 4 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 280192ac05bdf7..acdf163535b50a 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -209,5 +210,48 @@ public async Task DirectoryEntryWithData(bool async) await DisposeZipArchive(async, archive); await Assert.ThrowsAsync(() => CallZipFileExtractToDirectory(async, archivePath, GetTestFilePath())); } + + + [Fact] + public void OpenEncryptedTxtFile_ShouldReturnPlaintext() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); + using var stream = entry.Open(); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("Hello ZipCrypto!", content); + } + + [Fact] + public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\jpg.zip"; + string originalPath = @"C:\Users\spahontu\Downloads\test.jpg"; // original JPEG for comparison + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalPath), $"Original JPEG not found at {originalPath}"); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + + // Act: open decrypted + decompressed stream + using var stream = entry.Open(); + + // Read all bytes + using var ms = new MemoryStream(); + stream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Optional: compare with original file + byte[] expectedBytes = File.ReadAllBytes(originalPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + + } } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 7510c1c1052274..829b36c96ffc0e 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -41,12 +41,9 @@ - - - + + + @@ -54,28 +51,26 @@ - + - + - - - + + + + + + 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 cb7a104ca03ac2..2da00b8253f56c 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 @@ -741,16 +741,54 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool return checkSumStream; } + private byte CalculateZipCryptoCheckByte() + { + const ushort DataDescriptorFlag = 0x0008; // GPBF bit 3 + + // If data descriptor NOT used, the check byte is the MSB of CRC32 + if (((ushort)_generalPurposeBitFlag & DataDescriptorFlag) == 0) + return (byte)((_crc32 >> 24) & 0xFF); + + // If data descriptor IS used, the check byte is the MSB of the DOS time from the *local* header + return (byte)((ZipHelper.DateTimeToDosTime(_lastModified.DateTime) >> 8) & 0xFF); + } + + private bool IsZipCryptoEncrypted() + { + const ushort EncryptionFlag = 0x0001; + return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0; // && !UsesAes(); + } + + // TODO: Change based on specs + // private static bool UsesAes() => false; + private Stream GetDataDecompressor(Stream compressedStreamToRead) { + + Stream toDecompress = compressedStreamToRead; + if (IsZipCryptoEncrypted()) + { + // if (UsesAes()) for future + // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); + + ReadOnlySpan password = "123456789"; + if (password.IsEmpty) + throw new InvalidDataException("Password required for encrypted ZIP entry."); + + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + + // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. + toDecompress = new ZipCryptoDecryptionStream(toDecompress, password, expectedCheckByte); + } + Stream? uncompressedStream; switch (CompressionMethod) { case CompressionMethodValues.Deflate: - uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize); + uncompressedStream = new DeflateStream(toDecompress, CompressionMode.Decompress, _uncompressedSize); break; case CompressionMethodValues.Deflate64: - uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); + uncompressedStream = new DeflateManagedStream(toDecompress, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: default: @@ -758,7 +796,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) // IsOpenable is checked before this function is called Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); - uncompressedStream = compressedStreamToRead; + uncompressedStream = toDecompress; break; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs new file mode 100644 index 00000000000000..fa34517fb2e594 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.IO.Compression +{ + // Internal read-only, non-seekable stream that: + // - Initializes ZipCrypto keys from the password + // - Reads & decrypts the 12-byte header and validates the check byte + // - Decrypts subsequent bytes on Read(...) + internal sealed class ZipCryptoDecryptionStream : Stream + { + private readonly Stream _base; + private uint _key0; + private uint _key1; + private uint _key2; + private static readonly uint[] crc2Table = CreateCrc32Table(); + + private static uint[] CreateCrc32Table() { + + var table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int j = 0; j < 8; j++) + c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + return table; + + } + + public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlySpan password, byte expectedCheckByte) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + InitKeys(password); + ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes + } + + private void InitKeys(ReadOnlySpan password) + { + _key0 = 305419896; + _key1 = 591751049; + _key2 = 878082192; + + foreach (char ch in password) + { + UpdateKeys((byte)ch); + } + } + + private void ValidateHeader(byte expectedCheckByte) + { + byte[] hdr = new byte[12]; + int read = 0; + while (read < hdr.Length) + { + int n = _base.Read(hdr.AsSpan(read)); + if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); + read += n; + } + + for (int i = 0; i < hdr.Length; i++) + { + hdr[i] = DecryptByte(hdr[i]); + } + + if (hdr[11] != expectedCheckByte) + throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + } + + private void UpdateKeys(byte b) + { + _key0 = Crc32Update(_key0, b); + _key1 += (_key0 & 0xFF); + _key1 = _key1 * 134775813 + 1; + _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); + } + + private byte DecipherByte() + { + ushort temp = (ushort)(_key2 | 2); + return (byte)((temp * (temp ^ 1)) >> 8); + } + + private byte DecryptByte(byte ciph) + { + byte m = DecipherByte(); + byte plain = (byte)(ciph ^ m); + UpdateKeys(plain); + return plain; + } + + // ---- Stream overrides ---- + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) + throw new ArgumentOutOfRangeException(); + + int n = _base.Read(buffer, offset, count); + for (int i = 0; i < n; i++) + { + buffer[offset + i] = DecryptByte(buffer[offset + i]); + } + return n; + } + + public override int Read(Span destination) + { + int n = _base.Read(destination); + for (int i = 0; i < n; i++) + { + destination[i] = DecryptByte(destination[i]); + } + return n; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) _base.Dispose(); + base.Dispose(disposing); + } + + // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). + private static uint Crc32Update(uint crc, byte b) + { + return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + } +} From 255642bfafcde5138d93437831f6ab4667285554 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 21 Oct 2025 11:02:51 +0200 Subject: [PATCH 02/83] add tests with multiple entries with same password and entries with password and unprotected --- .../tests/ZipFile.Extract.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index acdf163535b50a..685b1aa778a95f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -253,5 +253,85 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() } + [Fact] + public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\combined.zip"; + string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); + + // Open archive with password + using var archive = ZipFile.OpenRead(zipPath); + + // 1) Validate hello.txt + var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + using (var txtStream = txtEntry.Open()) + using (var reader = new StreamReader(txtStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal("Hello ZipCrypto!", content); + } + + // 2) Validate test.jpg + var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + using (var jpgStream = jpgEntry.Open()) + using (var ms = new MemoryStream()) + { + jpgStream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Quick sanity: JPEG SOI marker + Assert.True(actualBytes.Length > 3, "JPEG too small"); + Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); + + // Full comparison with original file + byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + } + + [Fact] + public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDecryptBoth() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\combinedpass.zip"; + string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); + + // Open archive with password + using var archive = ZipFile.OpenRead(zipPath); + + // 1) Validate hello.txt + var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + using (var txtStream = txtEntry.Open()) + using (var reader = new StreamReader(txtStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal("Hello ZipCrypto!", content); + } + + // 2) Validate test.jpg + var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + using (var jpgStream = jpgEntry.Open()) + using (var ms = new MemoryStream()) + { + jpgStream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Quick sanity: JPEG SOI marker + Assert.True(actualBytes.Length > 3, "JPEG too small"); + Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); + + // Full comparison with original file + byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + } + } } From 968bb60525c615062748276ca28228a1219a3d73 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 21 Oct 2025 21:19:48 +0200 Subject: [PATCH 03/83] reading/extracting experimentation and tests --- .../IO/Compression/ZipTestHelper.ZipFile.cs | 4 +- .../System/IO/Compression/ZipTestHelper.cs | 2 +- .../ref/System.IO.Compression.ZipFile.cs | 4 + ...xtensions.ZipArchiveEntry.Extract.Async.cs | 26 +++ ...pFileExtensions.ZipArchiveEntry.Extract.cs | 23 +++ .../tests/ZipFile.Extract.cs | 159 +++++++++++++++++- .../ref/System.IO.Compression.cs | 2 + .../IO/Compression/ZipArchiveEntry.Async.cs | 22 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 52 +++++- .../Compression/ZipCryptoDecryptionStream.cs | 4 +- 10 files changed, 280 insertions(+), 18 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs index f5e1db167b95b1..f6aa5ee7b095fe 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs @@ -48,7 +48,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite: false); + return entry.ExtractToFileAsync(destinationFileName, overwrite: false, cancellationToken: default); } else { @@ -61,7 +61,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite); + return entry.ExtractToFileAsync(destinationFileName, overwrite, cancellationToken: default); } else { diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a3ccd52901601e..57782feb62b8e2 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync() : entry.Open(); + return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index f0948420b9eaf9..8e705b98936904 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -56,7 +56,11 @@ public static void ExtractToDirectory(this System.IO.Compression.ZipArchive sour public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 188f1542af3834..c4c571b1ca9837 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,6 +86,32 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, CancellationToken cancellationToken = default, string password = "") => + await ExtractToFileAsync(source, destinationFileName, false, cancellationToken, password).ConfigureAwait(false); + + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, CancellationToken cancellationToken = default, string password = "") + { + cancellationToken.ThrowIfCancellationRequested(); + + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + + FileStream fs = new FileStream(destinationFileName, fileStreamOptions); + await using (fs) + { + Stream es; + if (password.Length > 1) + es = await source.OpenAsync(cancellationToken, password).ConfigureAwait(false); + else + es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); + await using (es) + { + await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + } + } + + ExtractToFileFinalize(source, destinationFileName); + } + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index cbd0ebd901ba2d..fb00173c8237f8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -73,6 +73,29 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination ExtractToFileFinalize(source, destinationFileName); } + + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, string password) => + ExtractToFile(source, destinationFileName, false, password); + + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) + { + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + + using (FileStream fs = new FileStream(destinationFileName, fileStreamOptions)) + { + if (password.Length > 0) + { + using (Stream es = source.Open(password)) + es.CopyTo(fs); + } + else + using (Stream es = source.Open()) + es.CopyTo(fs); + } + + ExtractToFileFinalize(source, destinationFileName); + } + private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, out FileStreamOptions fileStreamOptions) { ArgumentNullException.ThrowIfNull(source); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 685b1aa778a95f..49614b6bdadcdc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -4,8 +4,10 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; +using Xunit.Sdk; namespace System.IO.Compression.Tests { @@ -219,13 +221,160 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); - using var stream = entry.Open(); + using var stream = entry.Open("123456789"); using var reader = new StreamReader(stream); string content = reader.ReadToEnd(); Assert.Equal("Hello ZipCrypto!", content); } + + [Fact] + public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() + { + + + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + string CorrectPassword = "123456789"; + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + // Act: Extract using password + entry.ExtractToFile(tempFile, overwrite: true, password: CorrectPassword); + + // Assert: File exists and content matches expected plaintext + Assert.True(File.Exists(tempFile), "Extracted file was not created."); + string content = File.ReadAllText(tempFile); + Assert.Equal("Hello ZipCrypto!", content); + + // Cleanup + File.Delete(tempFile); + } + + [Fact] + public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() + { + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + Assert.Throws(() => + { + entry.ExtractToFile(tempFile, overwrite: true, password: "wrongpass"); + }); + } + + [Fact] + public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() + { + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + Assert.Throws(() => + { + entry.ExtractToFile(tempFile, overwrite: true); // No password passed + }); + } + + + [Fact] + public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789"); + + Assert.True(File.Exists(tempFile), "Extracted file was not created."); + string content = await File.ReadAllTextAsync(tempFile); + Assert.Equal("Hello ZipCrypto!", content); + + File.Delete(tempFile); + } + + [Fact] + public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "wrongpass"); + }); + } + + + [Fact] + public async Task ExtractToFileAsync_WithoutPassword_ShouldThrow() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, cancellationToken: default); // No password passed + }); + } + + + + [Fact] + public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async_cancel.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, cts.Token, password: "123456789"); + }); + } + [Fact] public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() { @@ -239,7 +388,7 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); // Act: open decrypted + decompressed stream - using var stream = entry.Open(); + using var stream = entry.Open("123456789"); // Read all bytes using var ms = new MemoryStream(); @@ -267,7 +416,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() // 1) Validate hello.txt var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - using (var txtStream = txtEntry.Open()) + using (var txtStream = txtEntry.Open("123456789")) using (var reader = new StreamReader(txtStream)) { string content = reader.ReadToEnd(); @@ -276,7 +425,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() // 2) Validate test.jpg var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open()) + using (var jpgStream = jpgEntry.Open("123456789")) using (var ms = new MemoryStream()) { jpgStream.CopyTo(ms); @@ -316,7 +465,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec // 2) Validate test.jpg var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open()) + using (var jpgStream = jpgEntry.Open("123456789")) using (var ms = new MemoryStream()) { jpgStream.CopyTo(ms); 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 e0e0bd0eda52f2..cb68b33aa6ca0e 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -126,7 +126,9 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } + public System.IO.Stream Open(string password) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } } public enum ZipArchiveMode diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 030f2d1e25f635..3d77fbff6075d7 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -37,6 +37,24 @@ public async Task OpenAsync(CancellationToken cancellationToken = defaul } } + public async Task OpenAsync(CancellationToken cancellationToken = default, string password = "") + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfInvalidArchive(); + + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + case ZipArchiveMode.Create: + return OpenInWriteMode(); + case ZipArchiveMode.Update: + default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + return await OpenInUpdateModeAsync(cancellationToken).ConfigureAwait(false); + } + } + internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -193,14 +211,14 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo throw new InvalidDataException(message); } - private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) + private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken, ReadOnlyMemory password = default) { cancellationToken.ThrowIfCancellationRequested(); if (checkOpenable) await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); return OpenInReadModeGetDataCompressor( - await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false)); + await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false), password); } private async Task OpenInUpdateModeAsync(CancellationToken cancellationToken) 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 2da00b8253f56c..a4000ca2233cf9 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 @@ -47,6 +47,7 @@ public partial class ZipArchiveEntry private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; private readonly CompressionLevel _compressionLevel; + //private ReadOnlyMemory _password; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -159,6 +160,22 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + ///// + ///// Gets the password as a read-only memory block of characters. + ///// + ///// The password is stored in a read-only memory block to enhance security by minimizing + ///// exposure in memory. + //internal ReadOnlyMemory Password { + + // get + // { + // return _password; + // } + // set + // { + // _password = value; + // } + //} /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. @@ -362,6 +379,30 @@ public Stream Open() } } + + /// + /// Opens the entry. If the archive that the entry belongs to was opened in Read mode, the returned stream will be readable, and it may or may not be seekable. If Create mode, the returned stream will be writable and not seekable. If Update mode, the returned stream will be readable, writable, seekable, and support SetLength. + /// + /// A Stream that represents the contents of the entry. + /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. + /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. + /// The ZipArchive that this entry belongs to has been disposed. + public Stream Open(string password) + { + ThrowIfInvalidArchive(); + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + return OpenInReadMode(checkOpenable: true, password.AsMemory()); + case ZipArchiveMode.Create: + return OpenInWriteMode(); + case ZipArchiveMode.Update: + default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + return OpenInUpdateMode(); + } + } + /// /// Returns the FullName of the entry. /// @@ -762,7 +803,7 @@ private bool IsZipCryptoEncrypted() // TODO: Change based on specs // private static bool UsesAes() => false; - private Stream GetDataDecompressor(Stream compressedStreamToRead) + private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) { Stream toDecompress = compressedStreamToRead; @@ -771,7 +812,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) // if (UsesAes()) for future // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); - ReadOnlySpan password = "123456789"; if (password.IsEmpty) throw new InvalidDataException("Password required for encrypted ZIP entry."); @@ -803,17 +843,17 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) return uncompressedStream; } - private Stream OpenInReadMode(bool checkOpenable) + private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password = default) { if (checkOpenable) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); - return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData()); + return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } - private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData) + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - return GetDataDecompressor(compressedStream); + return GetDataDecompressor(compressedStream, password); } private WrappedStream OpenInWriteMode() diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs index fa34517fb2e594..f5d04128ce40fd 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs @@ -29,10 +29,10 @@ private static uint[] CreateCrc32Table() { } - public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlySpan password, byte expectedCheckByte) + public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeys(password); + InitKeys(password.Span); ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } From 19cbc8e28c10363474a9bfe692b8525231df7f01 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 22 Oct 2025 11:26:59 +0200 Subject: [PATCH 04/83] add enum for encryption and write methods for zipcrypto stream --- .../ref/System.IO.Compression.cs | 5 + .../src/System.IO.Compression.csproj | 2 +- .../System/IO/Compression/ZipArchiveEntry.cs | 26 ++--- ...DecryptionStream.cs => ZipCryptoStream.cs} | 97 ++++++++++++++++++- 4 files changed, 108 insertions(+), 22 deletions(-) rename src/libraries/System.IO.Compression/src/System/IO/Compression/{ZipCryptoDecryptionStream.cs => ZipCryptoStream.cs} (59%) 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 cb68b33aa6ca0e..821f737d83d027 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -130,6 +130,11 @@ public void Delete() { } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } + public enum EncryptionMethod : byte + { + None = (byte)0, + ZipCrypto = (byte)1, + } } public enum ZipArchiveMode { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 829b36c96ffc0e..f95ead16d5292c 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -70,7 +70,7 @@ - + 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 a4000ca2233cf9..e894c5a21107c0 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 @@ -160,22 +160,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } - ///// - ///// Gets the password as a read-only memory block of characters. - ///// - ///// The password is stored in a read-only memory block to enhance security by minimizing - ///// exposure in memory. - //internal ReadOnlyMemory Password { - - // get - // { - // return _password; - // } - // set - // { - // _password = value; - // } - //} /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. @@ -818,7 +802,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory byte expectedCheckByte = CalculateZipCryptoCheckByte(); // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. - toDecompress = new ZipCryptoDecryptionStream(toDecompress, password, expectedCheckByte); + toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } Stream? uncompressedStream; @@ -1696,6 +1680,14 @@ internal enum BitFlagValues : ushort UnicodeFileNameAndComment = 0x800 } + public enum EncryptionMethod : byte + { + None = 0, + ZipCrypto = 1 + //Aes256 = 4, + } + + internal enum CompressionMethodValues : ushort { Stored = 0x0, diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs similarity index 59% rename from src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs rename to src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index f5d04128ce40fd..b71a2891453035 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -7,8 +7,9 @@ namespace System.IO.Compression // - Initializes ZipCrypto keys from the password // - Reads & decrypts the 12-byte header and validates the check byte // - Decrypts subsequent bytes on Read(...) - internal sealed class ZipCryptoDecryptionStream : Stream + internal sealed class ZipCryptoStream : Stream { + private readonly bool _encrypting; private readonly Stream _base; private uint _key0; private uint _key1; @@ -29,13 +30,66 @@ private static uint[] CreateCrc32Table() { } - public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) + // decryption constructor + public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); InitKeys(password.Span); ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } + public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + _encrypting = true; + + InitKeys(password.Span); + CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); + } + + private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) + { + byte[] hdrPlain = new byte[12]; + + // 0..9: random + for (int i = 0; i < 10; i++) + hdrPlain[i] = 0; + + + // 10..11: check bytes + if (crc32.HasValue) + { + uint crc = crc32.Value; + hdrPlain[10] = (byte)((crc >> 16) & 0xFF); + hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + } + else + { + // Fallback when CRC32 is not yet known + hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); + } + + // Encrypt header and write + byte[] hdrCiph = new byte[12]; + for (int i = 0; i < 12; i++) + { + hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT + } + + _base.Write(hdrCiph, 0, hdrCiph.Length); + } + + + private byte EncryptByte(byte plain) + { + byte ks = DecipherByte(); + byte ciph = (byte)(plain ^ ks); + UpdateKeys(plain); + return ciph; + } + + private void InitKeys(ReadOnlySpan password) { _key0 = 305419896; @@ -125,8 +179,43 @@ public override int Read(Span destination) public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if (_encrypting) + { + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) + throw new ArgumentOutOfRangeException(); + + // Simple temporary buffer; no ArrayPool, no async + byte[] tmp = new byte[count]; + for (int i = 0; i < count; i++) + { + tmp[i] = EncryptByte(buffer[offset + i]); + } + _base.Write(tmp, 0, count); + return; + } + throw new NotSupportedException("Stream is in decryption (read-only) mode."); + } + + public override void Write(ReadOnlySpan buffer) + { + if (_encrypting) + { + // Simple temporary buffer; no ArrayPool, no async + byte[] tmp = new byte[buffer.Length]; + for (int i = 0; i < buffer.Length; i++) + { + tmp[i] = EncryptByte(buffer[i]); + } + _base.Write(tmp, 0, tmp.Length); + return; + } + throw new NotSupportedException("Stream is in decryption (read-only) mode."); + } + protected override void Dispose(bool disposing) { From 0dec73756974edbae5bf14c4a9ddd12e4ae87f23 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 27 Oct 2025 09:55:49 +0100 Subject: [PATCH 05/83] allow encryption of entries --- .../ZipFileExtensions.ZipArchive.Create.cs | 21 +- .../tests/ZipFile.Extract.cs | 236 +++++++++- .../ref/System.IO.Compression.cs | 2 + .../src/System/IO/Compression/ZipArchive.cs | 38 +- .../System/IO/Compression/ZipArchiveEntry.cs | 276 +++++++++++- .../System/IO/Compression/ZipCryptoStream.cs | 425 ++++++++++++++---- 6 files changed, 880 insertions(+), 118 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index a523577247fd43..3d9cbc1c180293 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -78,9 +78,9 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel) + string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password, encryption); using (fs) { @@ -93,12 +93,18 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio return entry; } - private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync) + private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync, + string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); + if (password != null && encryption == ZipArchiveEntry.EncryptionMethod.None) + { + throw new ArgumentException("password and encryption should both be set"); + } + // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. @@ -106,9 +112,12 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); - ZipArchiveEntry entry = compressionLevel.HasValue ? - destination.CreateEntry(entryName, compressionLevel.Value) : - destination.CreateEntry(entryName); + ZipArchiveEntry entry = password is not null ? (compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) + : destination.CreateEntry(entryName, password, encryption)) + : (compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value) + : destination.CreateEntry(entryName)); DateTime lastWrite = File.GetLastWriteTime(sourceFileName); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 49614b6bdadcdc..d0f470a8caaa3e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -13,6 +13,10 @@ namespace System.IO.Compression.Tests { public class ZipFile_Extract : ZipFileTestBase { + + private const string DownloadsDir = @"C:\Users\spahontu\Downloads"; + private static string NewPath(string file) => Path.Combine(DownloadsDir, file); + public static IEnumerable Get_ExtractToDirectoryNormal_Data() { foreach (bool async in _bools) @@ -482,5 +486,235 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec } } + + + [Fact] + public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() + { + // Arrange + const string downloadsDir = @"C:\Users\spahontu\Downloads"; + const string zipPath = $@"{downloadsDir}\zipcrypto_test.zip"; + const string entryName = "hello.txt"; + const string password = "P@ssw0rd!"; + const string expectedContent = "hello zipcrypto"; + + // Ensure target directory exists + Directory.CreateDirectory(downloadsDir); + + // Clean up any previous file + if (File.Exists(zipPath)) + File.Delete(zipPath); + + // ACT 1: Create the archive and write one encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Your custom overload that sets per-entry password & ZipCrypto + var entry = za.CreateEntry(entryName, password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + writer.Write(expectedContent); + } + + // ACT 2: Open the archive for reading and read back the content using the password + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + // Your custom entry decryption API: Open(password) + using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + + + [Fact] + public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_samepw.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + var items = new (string Name, string Content)[] + { + ("a.txt", "alpha"), + ("b/config.json", "{\"k\":1}"), + ("c/readme.md", "# readme"), + }; + const string password = "S@m3PW!"; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + // Act 1: Create with same password for all + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + foreach (var it in items) + { + var entry = za.CreateEntry(it.Name, password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read back using same password for each entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + foreach (var it in items) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + } + } + + [Fact] + public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_diffpw.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + var items = new (string Name, string Content, string Password)[] + { + ("d.txt", "delta", "pw-d"), + ("e/info.txt", "echo-info", "pw-e"), + ("f/sub/notes.txt", "foxtrot-notes", "pw-f"), + }; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + // Act 1: Create, each entry with its own password + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + foreach (var it in items) + { + var entry = za.CreateEntry(it.Name, it.Password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read back with matching password per entry, and also verify a wrong password fails + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + foreach (var it in items) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + + // Correct password + using (var r = new StreamReader(e!.Open(it.Password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + + // Wrong password should throw (ZipCrypto header check fails) + Assert.ThrowsAny(() => + { + using var _ = e.Open("WRONG-PASSWORD"); + }); + } + } + } + + [Fact] + public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_mixed.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string encPw = "EncOnly123!"; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + var encryptedItems = new (string Name, string Content)[] + { + ("secure/one.txt", "top-secret-1"), + ("secure/two.txt", "top-secret-2"), + }; + + var plainItems = new (string Name, string Content)[] + { + ("public/a.txt", "visible-a"), + ("public/b.txt", "visible-b"), + }; + + // Act 1: Create archive with both encrypted and plain entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Encrypted + foreach (var it in encryptedItems) + { + var entry = za.CreateEntry(it.Name, encPw, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + + // Plain + foreach (var it in plainItems) + { + var entry = za.CreateEntry(it.Name); // default: no encryption + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read backencrypted need password, plain do not + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + // Encrypted + foreach (var it in encryptedItems) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(encPw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + + // Plain + foreach (var it in plainItems) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + + // Ensure opening a plain entry with a password is rejected (or simply ignored depending on API) + Assert.ThrowsAny(() => + { + using var _ = e.Open("some-password"); + }); + } + } + } + + + //[Fact] + //public void OpenEncryptedTxtFile() + //{ + // string zipPath = @"C:\Users\spahontu\Downloads\zipcrypto_test_wr.zip"; + // using var archive = ZipFile.OpenRead(zipPath); + + // var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); + // using var stream = entry.Open("P@ssw0rd!"); + // using var reader = new StreamReader(stream); + // string content = reader.ReadToEnd(); + + // Assert.Equal("hello zipcrypto", content); + //} + + + } } -} 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 821f737d83d027..83acf40d183457 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -103,6 +103,8 @@ public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode public static System.Threading.Tasks.Task CreateAsync(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } 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 6e1e36fd63b8eb..c2534303c0c02b 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 @@ -9,6 +9,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; namespace System.IO.Compression @@ -267,6 +268,11 @@ public ZipArchiveEntry CreateEntry(string entryName) return DoCreateEntry(entryName, null); } + public ZipArchiveEntry CreateEntry(string entryName, string password, ZipArchiveEntry.EncryptionMethod encryption) + { + return DoCreateEntry(entryName, null, password, encryption); + } + /// /// Creates an empty entry in the Zip archive with the specified entry name. There are no restrictions on the names of entries. The last write time of the entry is set to the current time. If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name. /// @@ -282,6 +288,11 @@ public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressio return DoCreateEntry(entryName, compressionLevel); } + public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) + { + return DoCreateEntry(entryName, compressionLevel, password, encryption); + } + /// /// Releases the unmanaged resources used by ZipArchive and optionally finishes writing the archive and releases the managed resources. /// @@ -379,7 +390,8 @@ private set // New entries in the archive won't change its state. internal ChangeState Changed { get; private set; } - private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel) + private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel, + string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { ArgumentException.ThrowIfNullOrEmpty(entryName); @@ -389,10 +401,26 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ThrowIfDisposed(); - ZipArchiveEntry entry = compressionLevel.HasValue ? - new ZipArchiveEntry(this, entryName, compressionLevel.Value) : - new ZipArchiveEntry(this, entryName); - + ZipArchiveEntry entry; + if (compressionLevel.HasValue) + { + if (password != null) { + entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); + } else + { + entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); + } + } + else + { + if (password != null) + { + entry = new ZipArchiveEntry(this, entryName, password, encryption); + } + else { + entry = new ZipArchiveEntry(this, entryName); + } + } AddEntry(entry); return entry; 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 e894c5a21107c0..fac6862748e8f9 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 @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -47,7 +48,8 @@ public partial class ZipArchiveEntry private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; private readonly CompressionLevel _compressionLevel; - //private ReadOnlyMemory _password; + private string? _password; + private EncryptionMethod _encryptionMethod; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -161,6 +163,58 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + : this(archive, entryName, compressionLevel) + { + _password = password; + _encryptionMethod = encryptionMethod; + _generalPurposeBitFlag = 0; + + if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _isEncrypted = true; + } + else + { + _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; + _isEncrypted = false; + } + } + + internal ZipArchiveEntry(ZipArchive archive, string entryName, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + : this(archive, entryName) + { + _password = password; + _encryptionMethod = encryptionMethod; + _generalPurposeBitFlag = 0; + + if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _isEncrypted = true; + } + else + { + _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; + _isEncrypted = false; + } + } + + internal string Password + { + get => _password ?? string.Empty; + set => _password = value; + } + + internal EncryptionMethod Encryption + { + get => _encryptionMethod; + set => _encryptionMethod = value; + } + /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. /// @@ -377,6 +431,9 @@ public Stream Open(string password) switch (_archive.Mode) { case ZipArchiveMode.Read: + if (!_isEncrypted) { + throw new InvalidDataException("Entry is not encrypted"); + } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: return OpenInWriteMode(); @@ -723,32 +780,179 @@ private void DetectEntryNameVersion() } } - private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //{ + // // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + + // // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. + // // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header + // // Deflate64 is not supported on all platforms + // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate + // || CompressionMethod == CompressionMethodValues.Stored); + + // bool isIntermediateStream = true; + // Stream compressorStream; + // switch (CompressionMethod) + // { + // case CompressionMethodValues.Stored: + // compressorStream = backingStream; + // isIntermediateStream = false; + // break; + // case CompressionMethodValues.Deflate: + // case CompressionMethodValues.Deflate64: + // default: + // compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); + // break; + + // } + // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + // var checkSumStream = new CheckSumAndSizeWriteStream( + // compressorStream, + // backingStream, + // leaveCompressorStreamOpenOnClose, + // this, + // onClose, + // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => + // { + // thisRef._crc32 = checkSum; + // thisRef._uncompressedSize = currentPosition; + // thisRef._compressedSize = backing.Position - initialPosition; + // closeHandler?.Invoke(thisRef, EventArgs.Empty); + // }); + + // return checkSumStream; + //} + //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //{ + // // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream + + // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate + // || CompressionMethod == CompressionMethodValues.Stored + // || CompressionMethod == CompressionMethodValues.Deflate64); + + // bool isEncrypted = Encryption == _encryptionMethod; + // string? pwd = _password; + + + // // (A) Insert encrypting stream eagerly (ZipCrypto header is written NOW, before initialPosition capture) + // Stream targetSink = backingStream; + // if (isEncrypted) + // { + // if (string.IsNullOrEmpty(pwd)) + // throw new InvalidOperationException("Encrypted entry requires a non-empty password."); + + // // With streaming (GPBF bit 3), use DOS time low word for ZipCrypto header check bytes + // ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + // // Constructor writes 12-byte ZipCrypto header immediately + // targetSink = new ZipCryptoStream( + // baseStream: backingStream, + // password: pwd.AsMemory(), + // passwordVerifierLow2Bytes: verifierLow2Bytes, + // crc32: null); + // } + + // Stream compressorStream; + // bool isIntermediateStream = true; + + // switch (CompressionMethod) + // { + // case CompressionMethodValues.Stored: + // compressorStream = targetSink; + // // If not encrypted, there is no intermediate layer; otherwise ZipCrypto is an intermediate + // isIntermediateStream = isEncrypted; + // break; + + // case CompressionMethodValues.Deflate: + // case CompressionMethodValues.Deflate64: + // default: + // // NOTE: DeflateStream uses leaveBackingStreamOpen for its own inner stream, + // // which here is targetSink (possibly encrypting stream) + // compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); + // isIntermediateStream = true; + // break; + // } + + // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + + // // (C) Return the checksum/size wrapper; add encryption overhead (12 for ZipCrypto) to compressed size + // var checkSumStream = new CheckSumAndSizeWriteStream( + // compressorStream, + // backingStream, + // leaveCompressorStreamOpenOnClose, + // this, + // onClose, + // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => + // { + // // CRC is over plaintext (as your CheckSumAndSizeWriteStream computes) + // thisRef._crc32 = checkSum; + // thisRef._uncompressedSize = currentPosition; + + // long rawCompressed = backing.Position - initialPosition; + + // // Because ZipCrypto header was written BEFORE initialPosition was captured, + // // we must add the overhead explicitly. + // if (thisRef.Encryption == EncryptionMethod.ZipCrypto) + // { + // rawCompressed += 12; // 12 for ZipCrypto + // } + + // thisRef._compressedSize = rawCompressed; + // closeHandler?.Invoke(thisRef, EventArgs.Empty); + // }); + + // return checkSumStream; + //} + private CheckSumAndSizeWriteStream GetDataCompressor( + Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, string? password = null, EncryptionMethod encryption = EncryptionMethod.None) { - // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream - // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. - // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header - // Deflate64 is not supported on all platforms Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - || CompressionMethod == CompressionMethodValues.Stored); + || CompressionMethod == CompressionMethodValues.Stored + || CompressionMethod == CompressionMethodValues.Deflate64); + + bool isEncrypted = Encryption == encryption; // your internal property + string? pwd = password; + + // Build target sink (encrypting layer if needed). Header will be emitted on the first write. + Stream targetSink = backingStream; + if (IsZipCryptoEncrypted()) + { + if (string.IsNullOrEmpty(pwd)) + throw new InvalidOperationException("Encrypted entry requires a non-empty password."); + + // For streaming (GPBF bit 3 set in LOCAL HEADER), verifier = DOS time low word of that header + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + targetSink = new ZipCryptoStream( + baseStream: backingStream, + password: pwd.AsMemory(), + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: leaveBackingStreamOpen); // honor leaveOpen semantics + } - bool isIntermediateStream = true; Stream compressorStream; + bool isIntermediateStream = true; + switch (CompressionMethod) { case CompressionMethodValues.Stored: - compressorStream = backingStream; - isIntermediateStream = false; + compressorStream = targetSink; + isIntermediateStream = isEncrypted; // only intermediate if we added encryption break; + case CompressionMethodValues.Deflate: case CompressionMethodValues.Deflate64: default: - compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); + compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); + isIntermediateStream = true; break; - } + bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + var checkSumStream = new CheckSumAndSizeWriteStream( compressorStream, backingStream, @@ -757,9 +961,14 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool onClose, (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => { + // CRC over plaintext: thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - thisRef._compressedSize = backing.Position - initialPosition; + + // No +12 needed anymore: the header was written after initialPosition was captured. + long rawCompressed = backing.Position - initialPosition; + + thisRef._compressedSize = rawCompressed; closeHandler?.Invoke(thisRef, EventArgs.Empty); }); @@ -768,10 +977,8 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool private byte CalculateZipCryptoCheckByte() { - const ushort DataDescriptorFlag = 0x0008; // GPBF bit 3 - // If data descriptor NOT used, the check byte is the MSB of CRC32 - if (((ushort)_generalPurposeBitFlag & DataDescriptorFlag) == 0) + if ((_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) return (byte)((_crc32 >> 24) & 0xFF); // If data descriptor IS used, the check byte is the MSB of the DOS time from the *local* header @@ -862,6 +1069,31 @@ private WrappedStream OpenInWriteMode() return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } + private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryption = EncryptionMethod.None) + { + if (_everOpenedForWrite) + throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); + + // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded + _archive.DebugAssertIsStillArchiveStreamOwner(this); + + _everOpenedForWrite = true; + Changes |= ZipArchive.ChangeState.StoredData; + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => + { + // release the archive stream + var entry = (ZipArchiveEntry)o!; + entry._archive.ReleaseArchiveStream(entry); + entry._outstandingWriteStream = null; + }, + password, + encryption + ); + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); + + return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); + } + private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) @@ -1043,9 +1275,19 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { + + if (Encryption == EncryptionMethod.ZipCrypto) + { + // Streaming mode for encryption: sizes and CRC unknown upfront + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + Debug.Assert(_crc32 == 0); + } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header - if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) + else if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) { _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; compressedSizeTruncated = 0; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index b71a2891453035..a3e62d5cde9654 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,23 +1,258 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +//namespace System.IO.Compression +//{ +// // Internal read-only, non-seekable stream that: +// // - Initializes ZipCrypto keys from the password +// // - Reads & decrypts the 12-byte header and validates the check byte +// // - Decrypts subsequent bytes on Read(...) +// internal sealed class ZipCryptoStream : Stream +// { +// private readonly bool _encrypting; +// private readonly Stream _base; +// private uint _key0; +// private uint _key1; +// private uint _key2; +// private static readonly uint[] crc2Table = CreateCrc32Table(); + +// private static uint[] CreateCrc32Table() { + +// var table = new uint[256]; +// for (uint i = 0; i < 256; i++) +// { +// uint c = i; +// for (int j = 0; j < 8; j++) +// c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); +// table[i] = c; +// } +// return table; + +// } + +// // decryption constructor +// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) +// { +// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); +// InitKeys(password.Span); +// _encrypting = false; +// ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes +// } + +// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) +// { +// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); +// _encrypting = true; + +// InitKeys(password.Span); +// CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); +// } + +// private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) +// { +// byte[] hdrPlain = new byte[12]; + +// // 0..9: random +// for (int i = 0; i < 10; i++) +// hdrPlain[i] = 0; + + +// // 10..11: check bytes +// if (crc32.HasValue) +// { +// uint crc = crc32.Value; +// hdrPlain[10] = (byte)((crc >> 16) & 0xFF); +// hdrPlain[11] = (byte)((crc >> 24) & 0xFF); +// } +// else +// { +// // Fallback when CRC32 is not yet known +// hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); +// hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); +// } + +// // Encrypt header and write +// byte[] hdrCiph = new byte[12]; +// for (int i = 0; i < 12; i++) +// { +// hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT +// } + +// _base.Write(hdrCiph, 0, hdrCiph.Length); +// } + + +// private byte EncryptByte(byte plain) +// { +// byte ks = DecipherByte(); +// byte ciph = (byte)(plain ^ ks); +// UpdateKeys(plain); +// return ciph; +// } + + +// private void InitKeys(ReadOnlySpan password) +// { +// _key0 = 305419896; +// _key1 = 591751049; +// _key2 = 878082192; + +// foreach (char ch in password) +// { +// UpdateKeys((byte)ch); +// } +// } + +// private void ValidateHeader(byte expectedCheckByte) +// { +// byte[] hdr = new byte[12]; +// int read = 0; +// while (read < hdr.Length) +// { +// int n = _base.Read(hdr.AsSpan(read)); +// if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); +// read += n; +// } + +// for (int i = 0; i < hdr.Length; i++) +// { +// hdr[i] = DecryptByte(hdr[i]); +// } + +// if (hdr[11] != expectedCheckByte) +// throw new InvalidDataException("Invalid password for encrypted ZIP entry."); +// } + +// private void UpdateKeys(byte b) +// { +// _key0 = Crc32Update(_key0, b); +// _key1 += (_key0 & 0xFF); +// _key1 = _key1 * 134775813 + 1; +// _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); +// } + +// private byte DecipherByte() +// { +// ushort temp = (ushort)(_key2 | 2); +// return (byte)((temp * (temp ^ 1)) >> 8); +// } + +// private byte DecryptByte(byte ciph) +// { +// byte m = DecipherByte(); +// byte plain = (byte)(ciph ^ m); +// UpdateKeys(plain); +// return plain; +// } + +// // ---- Stream overrides ---- + +// public override bool CanRead => !_encrypting; +// public override bool CanSeek => false; +// public override bool CanWrite => _encrypting; +// public override long Length => throw new NotSupportedException(); +// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } +// public override void Flush() => _base.Flush(); + +// public override int Read(byte[] buffer, int offset, int count) +// { +// ArgumentNullException.ThrowIfNull(buffer); +// if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) +// throw new ArgumentOutOfRangeException(); + +// int n = _base.Read(buffer, offset, count); +// for (int i = 0; i < n; i++) +// { +// buffer[offset + i] = DecryptByte(buffer[offset + i]); +// } +// return n; +// } + +// public override int Read(Span destination) +// { +// int n = _base.Read(destination); +// for (int i = 0; i < n; i++) +// { +// destination[i] = DecryptByte(destination[i]); +// } +// return n; +// } + +// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); +// public override void SetLength(long value) => throw new NotSupportedException(); + +// public override void Write(byte[] buffer, int offset, int count) +// { +// if (_encrypting) +// { +// ArgumentNullException.ThrowIfNull(buffer); +// if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) +// throw new ArgumentOutOfRangeException(); + +// // Simple temporary buffer; no ArrayPool, no async +// byte[] tmp = new byte[count]; +// for (int i = 0; i < count; i++) +// { +// tmp[i] = EncryptByte(buffer[offset + i]); +// } +// _base.Write(tmp, 0, count); +// return; +// } +// throw new NotSupportedException("Stream is in decryption (read-only) mode."); +// } + +// public override void Write(ReadOnlySpan buffer) +// { +// if (_encrypting) +// { +// // Simple temporary buffer; no ArrayPool, no async +// byte[] tmp = new byte[buffer.Length]; +// for (int i = 0; i < buffer.Length; i++) +// { +// tmp[i] = EncryptByte(buffer[i]); +// } +// _base.Write(tmp, 0, tmp.Length); +// return; +// } +// throw new NotSupportedException("Stream is in decryption (read-only) mode."); +// } + + +// protected override void Dispose(bool disposing) +// { +// if (disposing) _base.Dispose(); +// base.Dispose(disposing); +// } + +// // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). +// private static uint Crc32Update(uint crc, byte b) +// { +// return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); +// } +// } +//} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + namespace System.IO.Compression { - // Internal read-only, non-seekable stream that: - // - Initializes ZipCrypto keys from the password - // - Reads & decrypts the 12-byte header and validates the check byte - // - Decrypts subsequent bytes on Read(...) internal sealed class ZipCryptoStream : Stream { private readonly bool _encrypting; private readonly Stream _base; + private readonly bool _leaveOpen; // NEW + private bool _headerWritten; // NEW + private bool _everWrotePayload; // NEW + private readonly ushort _verifierLow2Bytes; // NEW (DOS time low word when streaming) + private readonly uint? _crc32ForHeader; // NEW (CRC-based header when not streaming) + private uint _key0; private uint _key1; private uint _key2; private static readonly uint[] crc2Table = CreateCrc32Table(); - private static uint[] CreateCrc32Table() { - + private static uint[] CreateCrc32Table() + { var table = new uint[256]; for (uint i = 0; i < 256; i++) { @@ -27,79 +262,81 @@ private static uint[] CreateCrc32Table() { table[i] = c; } return table; - } - // decryption constructor + // Decryption constructor (unchanged semantics) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeys(password.Span); + InitKeysFromBytes(password.Span); + _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } - public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) + // ENCRYPTION constructor (header is now deferred to first write) + public ZipCryptoStream(Stream baseStream, + ReadOnlyMemory password, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false) // NEW { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); _encrypting = true; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = passwordVerifierLow2Bytes; + _crc32ForHeader = crc32; - InitKeys(password.Span); - CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); + InitKeysFromBytes(password.Span); + // NOTE: Do NOT write the 12-byte header here anymore. } - private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) + private void EnsureHeader() // NEW { - byte[] hdrPlain = new byte[12]; + if (!_encrypting || _headerWritten) return; + + Span hdrPlain = stackalloc byte[12]; - // 0..9: random + // bytes 0..9: random for (int i = 0; i < 10; i++) hdrPlain[i] = 0; - - // 10..11: check bytes - if (crc32.HasValue) + // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) + if (_crc32ForHeader.HasValue) { - uint crc = crc32.Value; + uint crc = _crc32ForHeader.Value; hdrPlain[10] = (byte)((crc >> 16) & 0xFF); hdrPlain[11] = (byte)((crc >> 24) & 0xFF); } else { - // Fallback when CRC32 is not yet known - hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); + hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); } - // Encrypt header and write + // Encrypt & write; update keys with PLAINTEXT per spec byte[] hdrCiph = new byte[12]; for (int i = 0; i < 12; i++) { - hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT + byte ks = DecipherByte(); + byte p = hdrPlain[i]; + hdrCiph[i] = (byte)(p ^ ks); + UpdateKeys(p); } - _base.Write(hdrCiph, 0, hdrCiph.Length); - } - - - private byte EncryptByte(byte plain) - { - byte ks = DecipherByte(); - byte ciph = (byte)(plain ^ ks); - UpdateKeys(plain); - return ciph; + _base.Write(hdrCiph, 0, 12); + _headerWritten = true; } - - private void InitKeys(ReadOnlySpan password) + private void InitKeysFromBytes(ReadOnlySpan password) // NEW (byte-based init) { _key0 = 305419896; _key1 = 591751049; _key2 = 878082192; - foreach (char ch in password) - { - UpdateKeys((byte)ch); - } + // ZipCrypto uses raw bytes; ASCII is the most interoperable (UTF8 also acceptable). + var bytes = System.Text.Encoding.ASCII.GetBytes(password.ToString()); + foreach (byte b in bytes) + UpdateKeys(b); } private void ValidateHeader(byte expectedCheckByte) @@ -108,15 +345,13 @@ private void ValidateHeader(byte expectedCheckByte) int read = 0; while (read < hdr.Length) { - int n = _base.Read(hdr.AsSpan(read)); + int n = _base.Read(hdr, read, hdr.Length - read); if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); read += n; } for (int i = 0; i < hdr.Length; i++) - { hdr[i] = DecryptByte(hdr[i]); - } if (hdr[11] != expectedCheckByte) throw new InvalidDataException("Invalid password for encrypted ZIP entry."); @@ -132,7 +367,7 @@ private void UpdateKeys(byte b) private byte DecipherByte() { - ushort temp = (ushort)(_key2 | 2); + uint temp = _key2 | 2; // use uint to avoid narrowing issues return (byte)((temp * (temp ^ 1)) >> 8); } @@ -146,35 +381,39 @@ private byte DecryptByte(byte ciph) // ---- Stream overrides ---- - public override bool CanRead => true; + public override bool CanRead => !_encrypting; public override bool CanSeek => false; - public override bool CanWrite => false; + public override bool CanWrite => _encrypting; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() { } + public override void Flush() => _base.Flush(); public override int Read(byte[] buffer, int offset, int count) { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) - throw new ArgumentOutOfRangeException(); - - int n = _base.Read(buffer, offset, count); - for (int i = 0; i < n; i++) + if (!_encrypting) { - buffer[offset + i] = DecryptByte(buffer[offset + i]); + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) + throw new ArgumentOutOfRangeException(); + + int n = _base.Read(buffer, offset, count); + for (int i = 0; i < n; i++) + buffer[offset + i] = DecryptByte(buffer[offset + i]); + return n; } - return n; + throw new NotSupportedException("Stream is in encryption (write-only) mode."); } public override int Read(Span destination) { - int n = _base.Read(destination); - for (int i = 0; i < n; i++) + if (!_encrypting) { - destination[i] = DecryptByte(destination[i]); + int n = _base.Read(destination); + for (int i = 0; i < n; i++) + destination[i] = DecryptByte(destination[i]); + return n; } - return n; + throw new NotSupportedException("Stream is in encryption (write-only) mode."); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); @@ -182,51 +421,59 @@ public override int Read(Span destination) public override void Write(byte[] buffer, int offset, int count) { - if (_encrypting) - { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) - throw new ArgumentOutOfRangeException(); + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) + throw new ArgumentOutOfRangeException(); + + EnsureHeader(); // NEW + _everWrotePayload = _everWrotePayload || (count > 0); - // Simple temporary buffer; no ArrayPool, no async - byte[] tmp = new byte[count]; - for (int i = 0; i < count; i++) - { - tmp[i] = EncryptByte(buffer[offset + i]); - } - _base.Write(tmp, 0, count); - return; + // Simple temp buffer; optimize with ArrayPool if desired + byte[] tmp = new byte[count]; + for (int i = 0; i < count; i++) + { + byte ks = DecipherByte(); + byte p = buffer[offset + i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); } - throw new NotSupportedException("Stream is in decryption (read-only) mode."); + _base.Write(tmp, 0, count); } public override void Write(ReadOnlySpan buffer) { - if (_encrypting) + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + + EnsureHeader(); // NEW + _everWrotePayload = _everWrotePayload || (buffer.Length > 0); + + byte[] tmp = new byte[buffer.Length]; + for (int i = 0; i < buffer.Length; i++) { - // Simple temporary buffer; no ArrayPool, no async - byte[] tmp = new byte[buffer.Length]; - for (int i = 0; i < buffer.Length; i++) - { - tmp[i] = EncryptByte(buffer[i]); - } - _base.Write(tmp, 0, tmp.Length); - return; + byte ks = DecipherByte(); + byte p = buffer[i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); } - throw new NotSupportedException("Stream is in decryption (read-only) mode."); + _base.Write(tmp, 0, tmp.Length); } - protected override void Dispose(bool disposing) { - if (disposing) _base.Dispose(); + if (disposing) + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + EnsureHeader(); + + if (!_leaveOpen) + _base.Dispose(); + } base.Dispose(disposing); } - // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). private static uint Crc32Update(uint crc, byte b) - { - return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); - } + => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } } From f30fbfb81c3306d86f452be677e4c89af8001ea0 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 11:29:36 +0100 Subject: [PATCH 06/83] add more read/write tests for update mode and small refactors --- .../ref/System.IO.Compression.ZipFile.cs | 1 + .../ZipFileExtensions.ZipArchive.Create.cs | 4 + .../tests/ZipFile.Extract.cs | 526 +++++++++++++++++- .../src/System/IO/Compression/ZipArchive.cs | 2 + .../System/IO/Compression/ZipArchiveEntry.cs | 182 +----- .../System/IO/Compression/ZipCryptoStream.cs | 263 +-------- 6 files changed, 562 insertions(+), 416 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 8e705b98936904..479ca0180cce98 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -48,6 +48,7 @@ public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 3d9cbc1c180293..3e70aa9e4daded 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -77,6 +77,10 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); + public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, + string sourceFileName, string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) => + DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); + internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index d0f470a8caaa3e..9de81530caf6c1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -701,20 +701,524 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } - //[Fact] - //public void OpenEncryptedTxtFile() - //{ - // string zipPath = @"C:\Users\spahontu\Downloads\zipcrypto_test_wr.zip"; - // using var archive = ZipFile.OpenRead(zipPath); - // var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); - // using var stream = entry.Open("P@ssw0rd!"); - // using var reader = new StreamReader(stream); - // string content = reader.ReadToEnd(); + [Fact] + public async Task Update_AddEncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("update_add.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial archive with one plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("plain.txt"); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("plain content"); + } + + // Act: Open in Update mode and add encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var encEntry = za.CreateEntry("secure/new.txt", "pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(encEntry.Open(), Encoding.UTF8); + await w.WriteAsync("secret data"); + } + + // Assert: Verify both entries exist and encrypted one decrypts correctly + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var plain = za.GetEntry("plain.txt"); + Assert.NotNull(plain); + using (var r = new StreamReader(plain!.Open(), Encoding.UTF8)) + Assert.Equal("plain content", await r.ReadToEndAsync()); + + var secure = za.GetEntry("secure/new.txt"); + Assert.NotNull(secure); + using (var r = new StreamReader(secure!.Open("pw123"), Encoding.UTF8)) + Assert.Equal("secret data", await r.ReadToEndAsync()); + } + } + + [Fact] + public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() + { + // Arrange + string zipPath = NewPath("update_delete.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/delete.txt", "delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("to be deleted"); + } + + // Act: Delete the encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/delete.txt"); + Assert.NotNull(e); + e!.Delete(); + } + + // Assert: Entry should not exist + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + Assert.Null(za.GetEntry("secure/delete.txt")); + } + } + + [Fact] + public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() + { + // Arrange + string zipPath = NewPath("update_copy.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); - // Assert.Equal("hello zipcrypto", content); - //} + const string pw = "copy-pw"; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("original content"); + } + + // Act: Copy encrypted entry to new name + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry("secure/original.txt"); + Assert.NotNull(src); + + // Read original + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + content = r.ReadToEnd(); + + // Create new entry with same password + var dst = za.CreateEntry("secure/copy.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(dst.Open(), Encoding.UTF8); + w.Write(content); + } + // Assert: Both entries exist and decrypt correctly + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var orig = za.GetEntry("secure/original.txt"); + var copy = za.GetEntry("secure/copy.txt"); + Assert.NotNull(orig); + Assert.NotNull(copy); + using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8)) + Assert.Equal("original content", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8)) + Assert.Equal("original content", await r2.ReadToEndAsync()); + } + } + + + [Fact] + public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("update_copy.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string pw = "copy-pw"; + const string originalName = "secure/original.txt"; + const string copyName = "secure/copy.txt"; + const string payload = "original content"; + + // Create archive and a single encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry(originalName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(payload); + } + + // Act: Open in Update mode and copy encrypted entry to a new name + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry(originalName); + Assert.NotNull(src); + + // READ-ONLY decrypt in Update mode (Option A): Open(password) returns a readable stream, + // does NOT mark the entry as modified, and does NOT materialize to an edit buffer. + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + content = await r.ReadToEndAsync(); + + // Optional: wrong password should fail early + Assert.ThrowsAny(() => + { + using var _ = src.Open("WRONG"); + }); + + // Create the destination entry with the same password and write the copied content. + var dst = za.CreateEntry(copyName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(dst.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(content); + } + + // Assert: Both entries exist and decrypt to the expected content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var orig = za.GetEntry(originalName); + var copy = za.GetEntry(copyName); + Assert.NotNull(orig); + Assert.NotNull(copy); + + using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + var text = await r1.ReadToEndAsync(); + Assert.Equal(payload, text); + } + + using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + var text = await r2.ReadToEndAsync(); + Assert.Equal(payload, text); + } + } + } + + + [Fact] + public void Update_OpenEncryptedEntry_WrongPassword_Throws() + { + string zipPath = NewPath("update_wrong_pw.zip"); + const string pw = "correct-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("secret"); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/file.txt"); + Assert.NotNull(e); + Assert.ThrowsAny(() => + { + using var _ = e.Open("wrong-pw"); + }); + } + } + + + [Fact] + public async Task Update_EditPlainEntry_RoundTrip() + { + string zipPath = NewPath("update_edit_plain.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("plain.txt"); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("original"); + } + + // Edit in Update mode + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("plain.txt"); + Assert.NotNull(e); + + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Verify updated content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("plain.txt"); + using var r = new StreamReader(e.Open(), Encoding.UTF8); + Assert.Equal("modified", await r.ReadToEndAsync()); + } + } + + + + [Fact] + public void Update_EditEncryptedEntryWithoutPassword_Throws() + { + string zipPath = NewPath("update_edit_encrypted.zip"); + const string pw = "edit-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("secret"); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/edit.txt"); + Assert.NotNull(e); + + // Should throw because edit-in-place for encrypted entries is not supported + Assert.Throws(() => + { + using var _ = e.Open(); // no password + }); + } + } + + + [Fact] + public async Task Update_MixedEntries_ReadEncrypted_EditPlain() + { + string zipPath = NewPath("update_mixed.zip"); + const string pw = "mixed-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial zip with encrypted and plain entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) + await w.WriteAsync("encrypted"); + + var plainEntry = za.CreateEntry("plain.txt"); + using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) + await w.WriteAsync("original"); + } + + // First update: read encrypted, modify plain + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var enc = za.GetEntry("secure/data.txt"); + Assert.NotNull(enc); + + string encryptedContent; + using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + encryptedContent = await r.ReadToEndAsync(); + + var plain = za.GetEntry("plain.txt"); + using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Second update: verify encrypted, re-modify plain + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var enc = za.GetEntry("secure/data.txt"); + using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + Assert.Equal("encrypted", await r.ReadToEndAsync()); + + var plain = za.GetEntry("plain.txt"); + using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Final read: verify both entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) + Assert.Equal("encrypted", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) + Assert.Equal("modified", await r2.ReadToEndAsync()); + } + } + + + + [Fact] + public async Task Update_ModifySameEncryptedEntryMultipleTimes() + { + string zipPath = NewPath("update_modify_multiple.zip"); + const string pw = "multi-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("version1"); + } + + // Modify entry multiple times + for (int i = 2; i <= 3; i++) + { + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/data.txt"); + Assert.NotNull(e); + + string oldContent; + using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) + oldContent = await r.ReadToEndAsync(); + + e.Delete(); // remove old entry + + var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); + await w.WriteAsync($"{oldContent}-version{i}"); + } + } + + // Assert final content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("secure/data.txt"); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); + var text = await r.ReadToEndAsync(); + Assert.Equal("version1-version2-version3", text); + } + } + + + [Fact] + public async Task Update_CopyEncryptedEntryToPlainEntry() + { + string zipPath = NewPath("update_copy_to_plain.zip"); + const string pw = "plain-copy"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("secret content"); + } + + // Copy encrypted content to a plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry("secure/original.txt"); + Assert.NotNull(src); + + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + content = await r.ReadToEndAsync(); + + var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption + using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); + await w.WriteAsync(content); + } + + // Assert both entries exist and content matches + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var enc = za.GetEntry("secure/original.txt"); + var plain = za.GetEntry("public/copy.txt"); + Assert.NotNull(enc); + Assert.NotNull(plain); + + using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) + Assert.Equal("secret content", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) + Assert.Equal("secret content", await r2.ReadToEndAsync()); + } + } + + + + [Fact] + public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string srcPath = NewPath("source_wrong_pw.txt"); + string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); + const string entryName = "secure/wrong.txt"; + const string correctPassword = "correct!"; + const string badPassword = "wrong!"; + const string payload = "secret data"; + + if (File.Exists(srcPath)) File.Delete(srcPath); + if (File.Exists(zipPath)) File.Delete(zipPath); + + File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntryFromFile( + sourceFileName: srcPath, + entryName: entryName, + compressionLevel: CompressionLevel.Optimal, + password: correctPassword, + encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + } + + // Act & Assert + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + + Assert.ThrowsAny(() => + { + using var _ = e!.Open(badPassword); + }); + } + } + + + [Fact] + public async Task CreateEntryFromFile_WithEncryption_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string srcPath = NewPath("source_plain.txt"); + string zipPath = NewPath("create_from_file_plain.zip"); + const string entryName = "plain/copy.txt"; + const string payload = "this is plain"; + const string pwd = "anything"; + + if (File.Exists(srcPath)) File.Delete(srcPath); + if (File.Exists(zipPath)) File.Delete(zipPath); + + await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntryFromFile( + sourceFileName: srcPath, + entryName: entryName, + compressionLevel: CompressionLevel.Optimal, + password: pwd, + encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + + using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string text = await r.ReadToEndAsync(); + Assert.Equal(payload, text); + + // Opening a plain entry with a password should throw + Assert.ThrowsAny(() => + { + using var _ = e.Open("some-password"); + }); + } } } + + +} 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 c2534303c0c02b..3bbe7343b27098 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 @@ -32,6 +32,8 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; + //private string _defaultPassword = ""; + //private ZipArchiveEntry.EncryptionMethod _defaultEncryption = ZipArchiveEntry.EncryptionMethod.None; #if DEBUG_FORCE_ZIP64 public bool _forceZip64; 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 fac6862748e8f9..05809ab56ebb2a 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 @@ -412,7 +412,6 @@ public Stream Open() return OpenInWriteMode(); case ZipArchiveMode.Update: default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); return OpenInUpdateMode(); } } @@ -440,7 +439,10 @@ public Stream Open(string password) case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return OpenInUpdateMode(); + + if (!_isEncrypted) throw new InvalidDataException("Entry is not encrypted."); + return OpenInReadMode(checkOpenable: true, password.AsMemory()); + } } @@ -486,7 +488,7 @@ internal long GetOffsetOfCompressedData() return _storedOffsetOfCompressedData.Value; } - private MemoryStream GetUncompressedData() + private MemoryStream GetUncompressedData(string? password = null) { if (_storedUncompressedData == null) { @@ -498,7 +500,23 @@ private MemoryStream GetUncompressedData() if (_originallyInArchive) { - using (Stream decompressor = OpenInReadMode(false)) + + + if (_isEncrypted) + { + // We dont support edit-in-place for encrypted entries without an explicit password flow. + // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Editing an encrypted entry in-place is not supported. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); + } + + + using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) { try { @@ -780,131 +798,8 @@ private void DetectEntryNameVersion() } } - //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) - //{ - // // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream - - // // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. - // // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header - // // Deflate64 is not supported on all platforms - // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - // || CompressionMethod == CompressionMethodValues.Stored); - - // bool isIntermediateStream = true; - // Stream compressorStream; - // switch (CompressionMethod) - // { - // case CompressionMethodValues.Stored: - // compressorStream = backingStream; - // isIntermediateStream = false; - // break; - // case CompressionMethodValues.Deflate: - // case CompressionMethodValues.Deflate64: - // default: - // compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); - // break; - - // } - // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - // var checkSumStream = new CheckSumAndSizeWriteStream( - // compressorStream, - // backingStream, - // leaveCompressorStreamOpenOnClose, - // this, - // onClose, - // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => - // { - // thisRef._crc32 = checkSum; - // thisRef._uncompressedSize = currentPosition; - // thisRef._compressedSize = backing.Position - initialPosition; - // closeHandler?.Invoke(thisRef, EventArgs.Empty); - // }); - - // return checkSumStream; - //} - //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) - //{ - // // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream - - // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - // || CompressionMethod == CompressionMethodValues.Stored - // || CompressionMethod == CompressionMethodValues.Deflate64); - - // bool isEncrypted = Encryption == _encryptionMethod; - // string? pwd = _password; - - - // // (A) Insert encrypting stream eagerly (ZipCrypto header is written NOW, before initialPosition capture) - // Stream targetSink = backingStream; - // if (isEncrypted) - // { - // if (string.IsNullOrEmpty(pwd)) - // throw new InvalidOperationException("Encrypted entry requires a non-empty password."); - - // // With streaming (GPBF bit 3), use DOS time low word for ZipCrypto header check bytes - // ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - - // // Constructor writes 12-byte ZipCrypto header immediately - // targetSink = new ZipCryptoStream( - // baseStream: backingStream, - // password: pwd.AsMemory(), - // passwordVerifierLow2Bytes: verifierLow2Bytes, - // crc32: null); - // } - - // Stream compressorStream; - // bool isIntermediateStream = true; - - // switch (CompressionMethod) - // { - // case CompressionMethodValues.Stored: - // compressorStream = targetSink; - // // If not encrypted, there is no intermediate layer; otherwise ZipCrypto is an intermediate - // isIntermediateStream = isEncrypted; - // break; - - // case CompressionMethodValues.Deflate: - // case CompressionMethodValues.Deflate64: - // default: - // // NOTE: DeflateStream uses leaveBackingStreamOpen for its own inner stream, - // // which here is targetSink (possibly encrypting stream) - // compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); - // isIntermediateStream = true; - // break; - // } - - // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - - // // (C) Return the checksum/size wrapper; add encryption overhead (12 for ZipCrypto) to compressed size - // var checkSumStream = new CheckSumAndSizeWriteStream( - // compressorStream, - // backingStream, - // leaveCompressorStreamOpenOnClose, - // this, - // onClose, - // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => - // { - // // CRC is over plaintext (as your CheckSumAndSizeWriteStream computes) - // thisRef._crc32 = checkSum; - // thisRef._uncompressedSize = currentPosition; - - // long rawCompressed = backing.Position - initialPosition; - - // // Because ZipCrypto header was written BEFORE initialPosition was captured, - // // we must add the overhead explicitly. - // if (thisRef.Encryption == EncryptionMethod.ZipCrypto) - // { - // rawCompressed += 12; // 12 for ZipCrypto - // } - - // thisRef._compressedSize = rawCompressed; - // closeHandler?.Invoke(thisRef, EventArgs.Empty); - // }); - - // return checkSumStream; - //} private CheckSumAndSizeWriteStream GetDataCompressor( - Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, string? password = null, EncryptionMethod encryption = EncryptionMethod.None) + Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream @@ -912,8 +807,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( || CompressionMethod == CompressionMethodValues.Stored || CompressionMethod == CompressionMethodValues.Deflate64); - bool isEncrypted = Encryption == encryption; // your internal property - string? pwd = password; + string? pwd = _password; // Build target sink (encrypting layer if needed). Header will be emitted on the first write. Stream targetSink = backingStream; @@ -940,7 +834,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( { case CompressionMethodValues.Stored: compressorStream = targetSink; - isIntermediateStream = isEncrypted; // only intermediate if we added encryption + isIntermediateStream = IsEncrypted; // only intermediate if we added encryption break; case CompressionMethodValues.Deflate: @@ -1068,32 +962,6 @@ private WrappedStream OpenInWriteMode() return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - - private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryption = EncryptionMethod.None) - { - if (_everOpenedForWrite) - throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); - - // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded - _archive.DebugAssertIsStillArchiveStreamOwner(this); - - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; - CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => - { - // release the archive stream - var entry = (ZipArchiveEntry)o!; - entry._archive.ReleaseArchiveStream(entry); - entry._outstandingWriteStream = null; - }, - password, - encryption - ); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); - - return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); - } - private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index a3e62d5cde9654..4c9df6ea95ea4b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,250 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -//namespace System.IO.Compression -//{ -// // Internal read-only, non-seekable stream that: -// // - Initializes ZipCrypto keys from the password -// // - Reads & decrypts the 12-byte header and validates the check byte -// // - Decrypts subsequent bytes on Read(...) -// internal sealed class ZipCryptoStream : Stream -// { -// private readonly bool _encrypting; -// private readonly Stream _base; -// private uint _key0; -// private uint _key1; -// private uint _key2; -// private static readonly uint[] crc2Table = CreateCrc32Table(); - -// private static uint[] CreateCrc32Table() { - -// var table = new uint[256]; -// for (uint i = 0; i < 256; i++) -// { -// uint c = i; -// for (int j = 0; j < 8; j++) -// c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); -// table[i] = c; -// } -// return table; - -// } - -// // decryption constructor -// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) -// { -// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); -// InitKeys(password.Span); -// _encrypting = false; -// ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes -// } - -// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) -// { -// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); -// _encrypting = true; - -// InitKeys(password.Span); -// CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); -// } - -// private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) -// { -// byte[] hdrPlain = new byte[12]; - -// // 0..9: random -// for (int i = 0; i < 10; i++) -// hdrPlain[i] = 0; - - -// // 10..11: check bytes -// if (crc32.HasValue) -// { -// uint crc = crc32.Value; -// hdrPlain[10] = (byte)((crc >> 16) & 0xFF); -// hdrPlain[11] = (byte)((crc >> 24) & 0xFF); -// } -// else -// { -// // Fallback when CRC32 is not yet known -// hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); -// hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); -// } - -// // Encrypt header and write -// byte[] hdrCiph = new byte[12]; -// for (int i = 0; i < 12; i++) -// { -// hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT -// } - -// _base.Write(hdrCiph, 0, hdrCiph.Length); -// } - - -// private byte EncryptByte(byte plain) -// { -// byte ks = DecipherByte(); -// byte ciph = (byte)(plain ^ ks); -// UpdateKeys(plain); -// return ciph; -// } - - -// private void InitKeys(ReadOnlySpan password) -// { -// _key0 = 305419896; -// _key1 = 591751049; -// _key2 = 878082192; - -// foreach (char ch in password) -// { -// UpdateKeys((byte)ch); -// } -// } - -// private void ValidateHeader(byte expectedCheckByte) -// { -// byte[] hdr = new byte[12]; -// int read = 0; -// while (read < hdr.Length) -// { -// int n = _base.Read(hdr.AsSpan(read)); -// if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); -// read += n; -// } - -// for (int i = 0; i < hdr.Length; i++) -// { -// hdr[i] = DecryptByte(hdr[i]); -// } - -// if (hdr[11] != expectedCheckByte) -// throw new InvalidDataException("Invalid password for encrypted ZIP entry."); -// } - -// private void UpdateKeys(byte b) -// { -// _key0 = Crc32Update(_key0, b); -// _key1 += (_key0 & 0xFF); -// _key1 = _key1 * 134775813 + 1; -// _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); -// } - -// private byte DecipherByte() -// { -// ushort temp = (ushort)(_key2 | 2); -// return (byte)((temp * (temp ^ 1)) >> 8); -// } - -// private byte DecryptByte(byte ciph) -// { -// byte m = DecipherByte(); -// byte plain = (byte)(ciph ^ m); -// UpdateKeys(plain); -// return plain; -// } - -// // ---- Stream overrides ---- - -// public override bool CanRead => !_encrypting; -// public override bool CanSeek => false; -// public override bool CanWrite => _encrypting; -// public override long Length => throw new NotSupportedException(); -// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } -// public override void Flush() => _base.Flush(); - -// public override int Read(byte[] buffer, int offset, int count) -// { -// ArgumentNullException.ThrowIfNull(buffer); -// if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) -// throw new ArgumentOutOfRangeException(); - -// int n = _base.Read(buffer, offset, count); -// for (int i = 0; i < n; i++) -// { -// buffer[offset + i] = DecryptByte(buffer[offset + i]); -// } -// return n; -// } - -// public override int Read(Span destination) -// { -// int n = _base.Read(destination); -// for (int i = 0; i < n; i++) -// { -// destination[i] = DecryptByte(destination[i]); -// } -// return n; -// } - -// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); -// public override void SetLength(long value) => throw new NotSupportedException(); - -// public override void Write(byte[] buffer, int offset, int count) -// { -// if (_encrypting) -// { -// ArgumentNullException.ThrowIfNull(buffer); -// if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) -// throw new ArgumentOutOfRangeException(); - -// // Simple temporary buffer; no ArrayPool, no async -// byte[] tmp = new byte[count]; -// for (int i = 0; i < count; i++) -// { -// tmp[i] = EncryptByte(buffer[offset + i]); -// } -// _base.Write(tmp, 0, count); -// return; -// } -// throw new NotSupportedException("Stream is in decryption (read-only) mode."); -// } - -// public override void Write(ReadOnlySpan buffer) -// { -// if (_encrypting) -// { -// // Simple temporary buffer; no ArrayPool, no async -// byte[] tmp = new byte[buffer.Length]; -// for (int i = 0; i < buffer.Length; i++) -// { -// tmp[i] = EncryptByte(buffer[i]); -// } -// _base.Write(tmp, 0, tmp.Length); -// return; -// } -// throw new NotSupportedException("Stream is in decryption (read-only) mode."); -// } - - -// protected override void Dispose(bool disposing) -// { -// if (disposing) _base.Dispose(); -// base.Dispose(disposing); -// } - -// // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). -// private static uint Crc32Update(uint crc, byte b) -// { -// return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); -// } -// } -//} -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream { private readonly bool _encrypting; private readonly Stream _base; - private readonly bool _leaveOpen; // NEW - private bool _headerWritten; // NEW - private bool _everWrotePayload; // NEW - private readonly ushort _verifierLow2Bytes; // NEW (DOS time low word when streaming) - private readonly uint? _crc32ForHeader; // NEW (CRC-based header when not streaming) + private readonly bool _leaveOpen; + private bool _headerWritten; + private bool _everWrotePayload; + private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) + private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) private uint _key0; private uint _key1; @@ -264,7 +31,7 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor (unchanged semantics) + // Decryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -278,7 +45,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null, - bool leaveOpen = false) // NEW + bool leaveOpen = false) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); _encrypting = true; @@ -287,16 +54,16 @@ public ZipCryptoStream(Stream baseStream, _crc32ForHeader = crc32; InitKeysFromBytes(password.Span); - // NOTE: Do NOT write the 12-byte header here anymore. } - private void EnsureHeader() // NEW + private void EnsureHeader() { if (!_encrypting || _headerWritten) return; Span hdrPlain = stackalloc byte[12]; - // bytes 0..9: random + // bytes 0..9 are random + // TODO: change to actual random data later for (int i = 0; i < 10; i++) hdrPlain[i] = 0; @@ -327,14 +94,14 @@ private void EnsureHeader() // NEW _headerWritten = true; } - private void InitKeysFromBytes(ReadOnlySpan password) // NEW (byte-based init) + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; _key1 = 591751049; _key2 = 878082192; // ZipCrypto uses raw bytes; ASCII is the most interoperable (UTF8 also acceptable). - var bytes = System.Text.Encoding.ASCII.GetBytes(password.ToString()); + var bytes = password.ToArray(); foreach (byte b in bytes) UpdateKeys(b); } @@ -426,10 +193,10 @@ public override void Write(byte[] buffer, int offset, int count) if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(); - EnsureHeader(); // NEW + EnsureHeader(); _everWrotePayload = _everWrotePayload || (count > 0); - // Simple temp buffer; optimize with ArrayPool if desired + // Simple buffer; optimize with ArrayPool if needed later byte[] tmp = new byte[count]; for (int i = 0; i < count; i++) { @@ -445,7 +212,7 @@ public override void Write(ReadOnlySpan buffer) { if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); - EnsureHeader(); // NEW + EnsureHeader(); _everWrotePayload = _everWrotePayload || (buffer.Length > 0); byte[] tmp = new byte[buffer.Length]; From aac140b1ae79d471c53c0afad51f0747e72d3a5e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 14:27:53 +0100 Subject: [PATCH 07/83] add default password and encryption method to ziparchive --- .../tests/ZipFile.Extract.cs | 131 +++++++++++++++++- .../ref/System.IO.Compression.cs | 1 + .../src/System/IO/Compression/ZipArchive.cs | 94 ++++++++++++- .../System/IO/Compression/ZipArchiveEntry.cs | 24 ++-- 4 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 9de81530caf6c1..873b3f07e61bab 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; @@ -669,7 +669,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } - // Act 2: Read backencrypted need password, plain do not + // Act 2: Read back—encrypted need password, plain do not using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) { // Encrypted @@ -1218,6 +1218,133 @@ public async Task CreateEntryFromFile_WithEncryption_RoundTrip() }); } } + + + + + [Fact] + public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() + { + Directory.CreateDirectory(DownloadsDir); + var zipPath = NewPath("defaults_apply.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string defaultPassword = "archive-pw"; + const string payload = "default encryption content"; + const string entryName = "secure/default.txt"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: defaultPassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e = za.CreateEntry(entryName); + + // OPEN → WRITE → DISPOSE (single scope) + using (var es = e.Open()) + { + var bytes = Encoding.UTF8.GetBytes(payload); + es.Write(bytes, 0, bytes.Length); + } + // no other entry opened while this one was open + } + + // Verify with the archive default password + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); + Assert.Equal(payload, r.ReadToEnd()); + } + } + + [Fact] + public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() + { + string zipPath = NewPath("defaults_multiple.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string defaultPassword = "archive-pw"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: defaultPassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e1 = za.CreateEntry("secure/one.txt"); + using (var s1 = e1.Open()) + { + var b = Encoding.UTF8.GetBytes("ONE"); + s1.Write(b, 0, b.Length); + } + + var e2 = za.CreateEntry("secure/two.txt"); + using (var s2 = e2.Open()) + { + var b = Encoding.UTF8.GetBytes("TWO"); + s2.Write(b, 0, b.Length); + } + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) + Assert.Equal("ONE", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) + Assert.Equal("TWO", await r2.ReadToEndAsync()); + } + } + + [Fact] + public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() + { + string zipPath = NewPath("override_default.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string archivePassword = "archive-pw"; + const string entryPassword = "entry-pw"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: archivePassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using (var s = e.Open()) + { + var b = Encoding.UTF8.GetBytes("OVERRIDE"); + s.Write(b, 0, b.Length); + } + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("secure/override.txt"); + Assert.NotNull(e); + + // Should succeed with entry password + using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) + Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); + + // Wrong: using archive default should fail + Assert.ThrowsAny(() => + { + using var _ = e.Open(archivePassword); + }); + } + } + } 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 83acf40d183457..9194f4aaf13d40 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -96,6 +96,7 @@ public ZipArchive(System.IO.Stream stream) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding) { } + public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, string? defaultPassword, System.IO.Compression.ZipArchiveEntry.EncryptionMethod defaultEncryption) { } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string Comment { get { throw null; } set { } } public System.Collections.ObjectModel.ReadOnlyCollection Entries { get { throw null; } } 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 3bbe7343b27098..5cd5ef5121d3ec 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 @@ -32,8 +32,10 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; - //private string _defaultPassword = ""; - //private ZipArchiveEntry.EncryptionMethod _defaultEncryption = ZipArchiveEntry.EncryptionMethod.None; + private readonly string? _defaultPassword; + private readonly ZipArchiveEntry.EncryptionMethod _defaultEncryption; + + #if DEBUG_FORCE_ZIP64 public bool _forceZip64; @@ -178,6 +180,66 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } + + public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) + : this(mode, leaveOpen, entryNameEncoding, backingStream: null, archiveStream: DecideArchiveStream(mode, stream), defaultPassword: defaultPassword, defaultEncryption: defaultEncryption) + { + ArgumentNullException.ThrowIfNull(stream); + + Stream? extraTempStream = null; + + try + { + _backingStream = null; + + if (ValidateMode(mode, stream)) + { + _backingStream = stream; + extraTempStream = stream = new MemoryStream(); + _backingStream.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + } + + _archiveStream = DecideArchiveStream(mode, stream); + + switch (mode) + { + case ZipArchiveMode.Create: + _readEntries = true; + break; + + case ZipArchiveMode.Read: + ReadEndOfCentralDirectory(); + break; + + case ZipArchiveMode.Update: + default: + Debug.Assert(mode == ZipArchiveMode.Update); + if (_archiveStream.Length == 0) + { + _readEntries = true; + } + else + { + ReadEndOfCentralDirectory(); + EnsureCentralDirectoryRead(); + + foreach (ZipArchiveEntry entry in _entries) + { + entry.ThrowIfNotOpenable(needToUncompress: false, needToLoadIntoMemory: true); + } + } + break; + } + } + catch + { + extraTempStream?.Dispose(); + throw; + } + } + + /// Helper constructor that initializes some of the essential ZipArchive /// information that other constructors initialize the same way. /// Validations, checks and entry collection need to be done outside this constructor. @@ -201,6 +263,22 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _firstDeletedEntryOffset = long.MaxValue; } + + private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, Stream? backingStream, Stream archiveStream, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) + : this(mode, leaveOpen, entryNameEncoding, backingStream, archiveStream) + { + _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; + _defaultEncryption = defaultEncryption; + + // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused); + // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. + if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) + { + throw new ArgumentException("A default password must be provided when defaultEncryption is ZipCrypto.", nameof(defaultPassword)); + } + } + + /// /// Gets or sets the optional archive comment. /// @@ -356,6 +434,9 @@ protected virtual void Dispose(bool disposing) internal uint NumberOfThisDisk => _numberOfThisDisk; + internal string? DefaultPassword => _defaultPassword; + internal ZipArchiveEntry.EncryptionMethod DefaultEncryption => _defaultEncryption; + internal Encoding? EntryNameAndCommentEncoding { get => _entryNameAndCommentEncoding; @@ -406,9 +487,11 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ZipArchiveEntry entry; if (compressionLevel.HasValue) { - if (password != null) { + if (password != null) + { entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); - } else + } + else { entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); } @@ -419,7 +502,8 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre { entry = new ZipArchiveEntry(this, entryName, password, encryption); } - else { + else + { entry = new ZipArchiveEntry(this, entryName); } } 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 05809ab56ebb2a..94e0cde6285974 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 @@ -98,6 +98,9 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _fileComment = cd.FileComment; _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); + + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -110,6 +113,8 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel CompressionMethod = CompressionMethodValues.Stored; } _generalPurposeBitFlag = MapDeflateCompressionOption(_generalPurposeBitFlag, _compressionLevel, CompressionMethod); + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry. @@ -161,6 +166,9 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) } Changes = ZipArchive.ChangeState.Unchanged; + + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) @@ -404,10 +412,12 @@ public Stream Open() { ThrowIfInvalidArchive(); + bool isEncrypted = !string.IsNullOrEmpty(_archive.DefaultPassword) && _archive.DefaultEncryption != EncryptionMethod.None; + switch (_archive.Mode) { case ZipArchiveMode.Read: - return OpenInReadMode(checkOpenable: true); + return isEncrypted ? OpenInReadMode(checkOpenable: true, _archive.DefaultPassword.AsMemory()) : OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: return OpenInWriteMode(); case ZipArchiveMode.Update: @@ -801,17 +811,17 @@ private void DetectEntryNameVersion() private CheckSumAndSizeWriteStream GetDataCompressor( Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { - // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream + // final chain: backingStream <- Encryption <- Deflate/Stored <- CheckSumAndSizeWriteStream Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate || CompressionMethod == CompressionMethodValues.Stored || CompressionMethod == CompressionMethodValues.Deflate64); - string? pwd = _password; + string? pwd = string.IsNullOrEmpty(_password) ? _archive.DefaultPassword : _password; - // Build target sink (encrypting layer if needed). Header will be emitted on the first write. + // Build encrypting layer if needed. Header will be emitted on the first write. Stream targetSink = backingStream; - if (IsZipCryptoEncrypted()) + if (IsZipCryptoEncrypted() || _archive.DefaultEncryption == EncryptionMethod.ZipCrypto) { if (string.IsNullOrEmpty(pwd)) throw new InvalidOperationException("Encrypted entry requires a non-empty password."); @@ -824,7 +834,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( password: pwd.AsMemory(), passwordVerifierLow2Bytes: verifierLow2Bytes, crc32: null, - leaveOpen: leaveBackingStreamOpen); // honor leaveOpen semantics + leaveOpen: leaveBackingStreamOpen); } Stream compressorStream; @@ -859,7 +869,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor( thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - // No +12 needed anymore: the header was written after initialPosition was captured. long rawCompressed = backing.Position - initialPosition; thisRef._compressedSize = rawCompressed; @@ -902,7 +911,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory byte expectedCheckByte = CalculateZipCryptoCheckByte(); - // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } From eb1a46079fca3fae6c69304432f9a453ec4ea578 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 15:06:24 +0100 Subject: [PATCH 08/83] avoid ambigous calls --- .../ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs | 4 ++-- .../System.IO.Compression/ref/System.IO.Compression.cs | 2 +- .../src/System/IO/Compression/ZipArchiveEntry.Async.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index c4c571b1ca9837..0fb8710db3160c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -99,8 +99,8 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string await using (fs) { Stream es; - if (password.Length > 1) - es = await source.OpenAsync(cancellationToken, password).ConfigureAwait(false); + if (!string.IsNullOrEmpty(password)) + es = await source.OpenAsync(password, cancellationToken).ConfigureAwait(false); else es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); await using (es) 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 9194f4aaf13d40..6628fe68080cd2 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -130,8 +130,8 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(string password) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 3d77fbff6075d7..48b946bd1f9669 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -37,7 +37,7 @@ public async Task OpenAsync(CancellationToken cancellationToken = defaul } } - public async Task OpenAsync(CancellationToken cancellationToken = default, string password = "") + public async Task OpenAsync(string password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); From 0a7c885412a4a3616161c410bd148d7f6a03a325 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 15:36:37 +0100 Subject: [PATCH 09/83] avoid ambigous calls and fi some nitpicks --- .../System/IO/Compression/ZipTestHelper.cs | 2 +- .../ref/System.IO.Compression.ZipFile.cs | 4 ++-- .../ZipFileExtensions.ZipArchive.Create.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 6 ++--- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 2 +- .../tests/ZipFile.Extract.cs | 2 +- .../src/System/IO/Compression/ZipArchive.cs | 24 ++++++------------- 7 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index 57782feb62b8e2..a3ccd52901601e 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); + return async ? await entry.OpenAsync() : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 479ca0180cce98..b678c368782f50 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -59,9 +59,9 @@ public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry sour public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 3e70aa9e4daded..1fe78d9e53f21b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -104,7 +104,7 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); - if (password != null && encryption == ZipArchiveEntry.EncryptionMethod.None) + if (!string.IsNullOrEmpty(password) && encryption == ZipArchiveEntry.EncryptionMethod.None) { throw new ArgumentException("password and encryption should both be set"); } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 0fb8710db3160c..5e4a4604db53aa 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,10 +86,10 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, CancellationToken cancellationToken = default, string password = "") => - await ExtractToFileAsync(source, destinationFileName, false, cancellationToken, password).ConfigureAwait(false); + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string? password, CancellationToken cancellationToken = default) => + await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, CancellationToken cancellationToken = default, string password = "") + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index fb00173c8237f8..8d252fc6a0b89b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -83,7 +83,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination using (FileStream fs = new FileStream(destinationFileName, fileStreamOptions)) { - if (password.Length > 0) + if (!string.IsNullOrEmpty(password)) { using (Stream es = source.Open(password)) es.CopyTo(fs); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 873b3f07e61bab..5598097bcfeca6 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -375,7 +375,7 @@ public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() cts.Cancel(); // Cancel immediately await Assert.ThrowsAsync(async () => { - await entry.ExtractToFileAsync(tempFile, overwrite: true, cts.Token, password: "123456789"); + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789", cts.Token); }); } 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 5cd5ef5121d3ec..458e7913129b11 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 @@ -270,7 +270,7 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; _defaultEncryption = defaultEncryption; - // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused); + // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused) ?? // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) { @@ -487,25 +487,15 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ZipArchiveEntry entry; if (compressionLevel.HasValue) { - if (password != null) - { - entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); - } - else - { - entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); - } + entry = !string.IsNullOrEmpty(password) + ? new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption) + : new ZipArchiveEntry(this, entryName, compressionLevel.Value); } else { - if (password != null) - { - entry = new ZipArchiveEntry(this, entryName, password, encryption); - } - else - { - entry = new ZipArchiveEntry(this, entryName); - } + entry = !string.IsNullOrEmpty(password) + ? new ZipArchiveEntry(this, entryName, password, encryption) + : new ZipArchiveEntry(this, entryName); } AddEntry(entry); From 817165cd110a29ef1033f59c4ebe4ff308247b7c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 5 Nov 2025 15:09:39 +0100 Subject: [PATCH 10/83] recognize aes encrypted files + build fais due to circular dependency with cryptography lib --- .../tests/ZipFile.Extract.cs | 22 +- .../ref/System.IO.Compression.cs | 3 + .../src/System.IO.Compression.csproj | 5 +- .../src/System/IO/Compression/AesStream.cs | 316 ++++++++++++++++++ .../System/IO/Compression/ZipArchiveEntry.cs | 127 ++++++- .../src/System/IO/Compression/ZipBlocks.cs | 52 +++ .../System/IO/Compression/ZipCryptoStream.cs | 2 +- 7 files changed, 500 insertions(+), 27 deletions(-) create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 5598097bcfeca6..0233394122f3e0 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -265,8 +265,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; - ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); - + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); if (File.Exists(tempFile)) File.Delete(tempFile); @@ -391,15 +390,12 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - // Act: open decrypted + decompressed stream using var stream = entry.Open("123456789"); - // Read all bytes using var ms = new MemoryStream(); stream.CopyTo(ms); byte[] actualBytes = ms.ToArray(); - // Optional: compare with original file byte[] expectedBytes = File.ReadAllBytes(originalPath); Assert.Equal(expectedBytes.Length, actualBytes.Length); Assert.Equal(expectedBytes, actualBytes); @@ -1243,13 +1239,11 @@ public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() { var e = za.CreateEntry(entryName); - // OPEN → WRITE → DISPOSE (single scope) using (var es = e.Open()) { var bytes = Encoding.UTF8.GetBytes(payload); es.Write(bytes, 0, bytes.Length); } - // no other entry opened while this one was open } // Verify with the archive default password @@ -1345,6 +1339,20 @@ public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() } } + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() + { + string zipPath = Path.Join(DownloadsDir, "plainwr.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + } 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 6628fe68080cd2..fe1ffe0e3030ef 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -137,6 +137,9 @@ public enum EncryptionMethod : byte { None = (byte)0, ZipCrypto = (byte)1, + Aes128 = (byte)2, + Aes192 = (byte)3, + Aes256 = (byte)4, } } public enum ZipArchiveMode diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index f95ead16d5292c..4ff73a2bf41a18 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) @@ -71,9 +71,10 @@ + - + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs new file mode 100644 index 00000000000000..271eeb4e5a8b09 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace System.IO.Compression +{ + internal sealed class AesStream : Stream + { + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly int _keySizeBits; + private readonly bool _ae2; + private readonly uint? _crc32ForHeader; + private readonly Aes _aes; + private ICryptoTransform? _aesEncryptor; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + private readonly HMACSHA1 _hmac; +#pragma warning restore CA1416 + private readonly byte[] _counterBlock = new byte[16]; + private byte[]? _key; + private byte[]? _hmacKey; + private byte[]? _salt; + private byte[]? _passwordVerifier; + private bool _headerWritten; + private bool _headerRead; + private long _position; + private readonly ReadOnlyMemory _password; + private bool _disposed; + + public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) + { + ArgumentNullException.ThrowIfNull(baseStream); + + + _baseStream = baseStream; + _password = password; + _encrypting = encrypting; + _keySizeBits = keySizeBits; + _ae2 = ae2; + _crc32ForHeader = crc32; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + _aes = Aes.Create(); +#pragma warning restore CA1416 + _aes.Mode = CipherMode.ECB; + _aes.Padding = PaddingMode.None; + +#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? + _hmac = new HMACSHA1(); +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +#pragma warning restore CA1416 + + if (_encrypting) + { + GenerateKeys(); + InitCipher(); + } + } + + private void GenerateKeys() + { + int saltSize = _keySizeBits / 16; // 8 for AES-128, 12 for AES-192, 16 for AES-256 + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + // WinZip AES uses SHA1 for PBKDF2 + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + + _key = new byte[_keySizeBits / 8]; + _hmacKey = new byte[32]; + _passwordVerifier = new byte[2]; + + Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); + Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); + Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + + _hmac.Key = _hmacKey; + } + + private void InitCipher() + { + if (_key is null) + throw new InvalidOperationException("Keys have not been generated."); + + _aes.Key = _key; + _aesEncryptor = _aes.CreateEncryptor(); + } + + private void WriteHeader() + { + if (_headerWritten) return; + + if (_salt is null || _passwordVerifier is null) + throw new InvalidOperationException("Keys have not been generated."); + + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + + if (_ae2 && _crc32ForHeader.HasValue) + { + Span crcBytes = stackalloc byte[4]; + BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); + _baseStream.Write(crcBytes); + } + + _headerWritten = true; + } + + private void ReadHeader() + { + if (_headerRead) return; + + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // WinZip AES uses SHA1 for PBKDF2 + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + + _key = new byte[_keySizeBits / 8]; + _hmacKey = new byte[32]; + _passwordVerifier = new byte[2]; + + Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); + Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); + Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier)) + throw new InvalidDataException("Invalid password."); + + _hmac.Key = _hmacKey; + InitCipher(); + + if (_ae2) + { + byte[] crcBytes = new byte[4]; + _baseStream.ReadExactly(crcBytes); + // CRC can be validated later if needed + } + + _headerRead = true; + } + + private void ProcessBlock(byte[] buffer, int offset, int count) + { + if (_aesEncryptor is null) + throw new InvalidOperationException("Cipher has not been initialized."); + + int processed = 0; + while (processed < count) + { + IncrementCounter(); + byte[] keystream = new byte[16]; + _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + + int blockSize = Math.Min(16, count - processed); + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + processed += blockSize; + } + } + + private void IncrementCounter() + { + for (int i = 15; i >= 0; i--) + { + if (++_counterBlock[i] != 0) break; + } + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + byte[] tmp = new byte[count]; + Buffer.BlockCopy(buffer, offset, tmp, 0, count); + ProcessBlock(tmp, 0, count); + _baseStream.Write(tmp, 0, count); + _position += count; + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int n = _baseStream.Read(buffer, offset, count); + if (n > 0) + { + ProcessBlock(buffer, offset, n); + _position += n; + } + + return n; + } + + public override void Write(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; + } + + public override int Read(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int n = _baseStream.Read(buffer); + if (n > 0) + { + byte[] tmp = buffer[..n].ToArray(); + ProcessBlock(tmp, 0, n); + tmp.CopyTo(buffer); + _position += n; + } + + return n; + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + try + { + if (_headerWritten || _headerRead) + { + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + if (_encrypting) + { + _baseStream.Write(authCode); + } + else + { + // For decryption, read and validate footer + byte[] storedAuth = new byte[authCode.Length]; + _baseStream.ReadExactly(storedAuth); + if (!storedAuth.AsSpan().SequenceEqual(authCode)) + throw new InvalidDataException("Authentication code mismatch."); + } + } + } + + _baseStream.Flush(); + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + } + } + + _disposed = true; + base.Dispose(disposing); + } + + public override bool CanRead => !_encrypting && !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => _encrypting && !_disposed; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _baseStream.Flush(); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} 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 94e0cde6285974..f830b0ba8a498e 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 @@ -440,7 +440,8 @@ public Stream Open(string password) switch (_archive.Mode) { case ZipArchiveMode.Read: - if (!_isEncrypted) { + if (!IsEncrypted) + { throw new InvalidDataException("Entry is not encrypted"); } return OpenInReadMode(checkOpenable: true, password.AsMemory()); @@ -488,13 +489,42 @@ internal long GetOffsetOfCompressedData() { if (_storedOffsetOfCompressedData == null) { + // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - // by calling this, we are using local header _storedEntryNameBytes.Length and extraFieldLength - // to find start of data, but still using central directory size information - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; + + long baseOffset; + + if (!IsEncrypted || IsZipCryptoEncrypted()) + { + // Non-AES case: just skip the local header + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + else + { + // AES case + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + + // Adjust for AES salt + password verifier using _encryptionMethod + int saltSize = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 8, + EncryptionMethod.Aes192 => 12, + EncryptionMethod.Aes256 => 16, + _ => throw new InvalidDataException("Unknown AES encryption method") + }; + + baseOffset += saltSize + 2; // salt + password verifier + } + + _storedOffsetOfCompressedData = baseOffset; } + return _storedOffsetOfCompressedData.Value; } @@ -891,11 +921,14 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { const ushort EncryptionFlag = 0x0001; - return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0; // && !UsesAes(); + return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0 && !IsAesEncrypted(); } - // TODO: Change based on specs - // private static bool UsesAes() => false; + private bool IsAesEncrypted() + { + // Compression method 99 indicates AES encryption + return _storedCompressionMethod == CompressionMethodValues.Aes; + } private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) { @@ -903,9 +936,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory Stream toDecompress = compressedStreamToRead; if (IsZipCryptoEncrypted()) { - // if (UsesAes()) for future - // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); - if (password.IsEmpty) throw new InvalidDataException("Password required for encrypted ZIP entry."); @@ -913,6 +943,28 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } + else if (IsAesEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + + // Determine key size based on encryption method + int keySizeBits = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") + }; + + // For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field + // The _crc32 field should contain the CRC value for AE-2 + bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; + + // Read and parse the AES extra field to get necessary parameters + toDecompress = new AesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); + } + Stream? uncompressedStream; switch (CompressionMethod) @@ -993,6 +1045,7 @@ private WrappedStream OpenInUpdateMode() }); } + private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1003,12 +1056,41 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st { return false; } - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + if (!IsEncrypted && !ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; return false; } + else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); + + byte? aesStrength; + ushort? originalCompressionMethod; + + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod)) + { + message = SR.LocalFileHeaderCorrupt; + return false; + } + + // Save encryption info for later use + if (aesStrength.HasValue) + { + _encryptionMethod = aesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + } + if (originalCompressionMethod.HasValue) + { + _storedCompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + } + } // when this property gets called, some duplicated work long offsetOfCompressedData = GetOffsetOfCompressedData(); if (!IsOpenableFinalVerifications(needToLoadIntoMemory, offsetOfCompressedData, out message)) @@ -1027,7 +1109,8 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m message = null; if (needToUncompress) { - if (CompressionMethod != CompressionMethodValues.Stored && + if (!IsEncrypted && + CompressionMethod != CompressionMethodValues.Stored && CompressionMethod != CompressionMethodValues.Deflate && CompressionMethod != CompressionMethodValues.Deflate64) { @@ -1038,6 +1121,13 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m }; return false; } + else + { + if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + return true; + } + } } if (_diskNumberStart != _archive.NumberOfThisDisk) { @@ -1801,8 +1891,10 @@ internal enum BitFlagValues : ushort public enum EncryptionMethod : byte { None = 0, - ZipCrypto = 1 - //Aes256 = 4, + ZipCrypto = 1, + Aes128 = 2, + Aes192 = 3, + Aes256 = 4 } @@ -1812,7 +1904,8 @@ internal enum CompressionMethodValues : ushort Deflate = 0x8, Deflate64 = 0x9, BZip2 = 0xC, - LZMA = 0xE + LZMA = 0xE, + Aes = 99 } internal sealed class LocalHeaderOffsetComparer : Comparer 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..e6afca04182ca0 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 @@ -670,6 +670,58 @@ public static bool TrySkipBlock(Stream stream) bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } + + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod) + { + aesStrength = null; + originalCompressionMethod = null; + + BinaryReader reader = new BinaryReader(stream); + + // Read the first 4 bytes (local file header signature) + byte[] signatureBytes = reader.ReadBytes(4); + if (!signatureBytes.AsSpan().SequenceEqual(ZipLocalFileHeader.SignatureConstantBytes)) + { + return false; // Not a valid local file header + } + // Read fixed-size fields after signature + // Local file header layout: + // signature (4) + version (2) + flags (2) + compression (2) + + // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + + // uncompressed size (4) + name length (2) + extra length (2) + reader.ReadBytes(22); // Skip version through sizes + ushort nameLength = reader.ReadUInt16(); + ushort extraLength = reader.ReadUInt16(); + + // Skip file name + stream.Seek(nameLength, SeekOrigin.Current); + + // Parse extra fields + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; + while (stream.Position < extraEnd) + { + ushort headerId = reader.ReadUInt16(); + ushort dataSize = reader.ReadUInt16(); + + if (headerId == 0x9901) // AES extra field + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + reader.ReadBytes(2); + reader.ReadBytes(2); // Vendor ID + aesStrength = reader.ReadByte(); // 1, 2, or 3 + originalCompressionMethod = reader.ReadUInt16(); + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } + } + + return true; + } + } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 4c9df6ea95ea4b..a3589e210ebec0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -40,7 +40,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } - // ENCRYPTION constructor (header is now deferred to first write) + // Encryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, From 797921db80b20cc810fff8fbeabd8e0d76134329 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 11 Nov 2025 16:13:09 +0100 Subject: [PATCH 11/83] fixed some comments --- .../IO/Compression/ZipTestHelper.ZipFile.cs | 4 +- .../src/System.IO.Compression.csproj | 2 +- .../{AesStream.cs => WinZipAesStream.cs} | 223 ++++++++++++------ .../src/System/IO/Compression/ZipArchive.cs | 74 +----- .../System/IO/Compression/ZipArchiveEntry.cs | 24 +- 5 files changed, 176 insertions(+), 151 deletions(-) rename src/libraries/System.IO.Compression/src/System/IO/Compression/{AesStream.cs => WinZipAesStream.cs} (55%) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs index f6aa5ee7b095fe..f5e1db167b95b1 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs @@ -48,7 +48,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite: false, cancellationToken: default); + return entry.ExtractToFileAsync(destinationFileName, overwrite: false); } else { @@ -61,7 +61,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite, cancellationToken: default); + return entry.ExtractToFileAsync(destinationFileName, overwrite); } else { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 4ff73a2bf41a18..64799e955ab637 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -71,7 +71,7 @@ - + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs similarity index 55% rename from src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs rename to src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 271eeb4e5a8b09..122dc298f01725 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -1,11 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; namespace System.IO.Compression { - internal sealed class AesStream : Stream + internal sealed class WinZipAesStream : Stream { private readonly Stream _baseStream; private readonly bool _encrypting; @@ -27,8 +30,11 @@ internal sealed class AesStream : Stream private long _position; private readonly ReadOnlyMemory _password; private bool _disposed; + private bool _authCodeValidated; + private readonly byte[] _authCodeBuffer = new byte[20]; // HMACSHA1 is 20 bytes + private int _authCodeBufferCount; - public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) { ArgumentNullException.ThrowIfNull(baseStream); @@ -58,14 +64,12 @@ public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypti } } - private void GenerateKeys() + private void DeriveKeysFromPassword() { - int saltSize = _keySizeBits / 16; // 8 for AES-128, 12 for AES-192, 16 for AES-256 - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); + Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - // WinZip AES uses SHA1 for PBKDF2 - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); _key = new byte[_keySizeBits / 8]; _hmacKey = new byte[32]; @@ -74,16 +78,26 @@ private void GenerateKeys() Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + } + + private void GenerateKeys() + { + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(); - _hmac.Key = _hmacKey; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; } private void InitCipher() { - if (_key is null) - throw new InvalidOperationException("Keys have not been generated."); + Debug.Assert(_key is not null, "_key is not null"); - _aes.Key = _key; + _aes.Key = _key!; _aesEncryptor = _aes.CreateEncryptor(); } @@ -91,8 +105,7 @@ private void WriteHeader() { if (_headerWritten) return; - if (_salt is null || _passwordVerifier is null) - throw new InvalidOperationException("Keys have not been generated."); + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); _baseStream.Write(_salt); _baseStream.Write(_passwordVerifier); @@ -118,21 +131,14 @@ private void ReadHeader() byte[] verifier = new byte[2]; _baseStream.ReadExactly(verifier); - // WinZip AES uses SHA1 for PBKDF2 - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + DeriveKeysFromPassword(); - _key = new byte[_keySizeBits / 8]; - _hmacKey = new byte[32]; - _passwordVerifier = new byte[2]; - - Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); - Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); - Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier)) + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) throw new InvalidDataException("Invalid password."); - _hmac.Key = _hmacKey; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; InitCipher(); if (_ae2) @@ -147,17 +153,22 @@ private void ReadHeader() private void ProcessBlock(byte[] buffer, int offset, int count) { - if (_aesEncryptor is null) - throw new InvalidOperationException("Cipher has not been initialized."); + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); int processed = 0; + byte[] keystream = new byte[16]; while (processed < count) { IncrementCounter(); - byte[] keystream = new byte[16]; _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + // For the last block, we may use less than 16 bytes of the keystream + // This is correct CTR mode behavior - we only use as many bytes as needed int blockSize = Math.Min(16, count - processed); + + // XOR the data with the keystream + // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream + // The unused bytes are discarded, which is the expected for (int i = 0; i < blockSize; i++) { buffer[offset + processed + i] ^= keystream[i]; @@ -176,25 +187,62 @@ private void IncrementCounter() } } - public override void Write(byte[] buffer, int offset, int count) + private void WriteAuthCode() + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + _baseStream.Write(authCode); + } + + _authCodeValidated = true; + } + + private void ValidateAuthCode() + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the stored authentication code from the stream + byte[] storedAuth = new byte[expectedAuth.Length]; + _baseStream.ReadExactly(storedAuth); + + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + + private void WriteCore(ReadOnlySpan buffer) { - ValidateBufferArguments(buffer, offset, count); ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); WriteHeader(); - byte[] tmp = new byte[count]; - Buffer.BlockCopy(buffer, offset, tmp, 0, count); - ProcessBlock(tmp, 0, count); - _baseStream.Write(tmp, 0, count); - _position += count; + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; } - public override int Read(byte[] buffer, int offset, int count) + private int ReadCore(Span buffer) { - ValidateBufferArguments(buffer, offset, count); ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) @@ -203,31 +251,85 @@ public override int Read(byte[] buffer, int offset, int count) if (!_headerRead) ReadHeader(); - int n = _baseStream.Read(buffer, offset, count); + int n = _baseStream.Read(buffer); + + // Check if we reached the end of the stream + if (n == 0 && !_authCodeValidated) + { + ValidateAuthCode(); + return 0; + } + if (n > 0) { - ProcessBlock(buffer, offset, n); + // Process the data in-place for reads (it's already in the buffer) + // We need to temporarily copy to array for HMAC processing + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer); _position += n; } return n; } + // All Write overloads redirect to Write(ReadOnlySpan) + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - WriteHeader(); - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); - _position += buffer.Length; + return Core(buffer, cancellationToken); + + async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += buffer.Length; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return ReadCore(new Span(buffer, offset, count)); } public override int Read(Span buffer) + { + return ReadCore(buffer); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -237,12 +339,13 @@ public override int Read(Span buffer) if (!_headerRead) ReadHeader(); - int n = _baseStream.Read(buffer); + int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (n > 0) { - byte[] tmp = buffer[..n].ToArray(); - ProcessBlock(tmp, 0, n); - tmp.CopyTo(buffer); + // Process the data - work with the Memory span + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer.Span); _position += n; } @@ -258,26 +361,10 @@ protected override void Dispose(bool disposing) { try { - if (_headerWritten || _headerRead) + // For encryption, write the auth code when closing + if (_encrypting && _headerWritten && !_authCodeValidated) { - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; - - if (authCode is not null) - { - if (_encrypting) - { - _baseStream.Write(authCode); - } - else - { - // For decryption, read and validate footer - byte[] storedAuth = new byte[authCode.Length]; - _baseStream.ReadExactly(storedAuth); - if (!storedAuth.AsSpan().SequenceEqual(authCode)) - throw new InvalidDataException("Authentication code mismatch."); - } - } + WriteAuthCode(); } _baseStream.Flush(); 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 458e7913129b11..bc16f2119bbf88 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 @@ -180,62 +180,16 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } - public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(mode, leaveOpen, entryNameEncoding, backingStream: null, archiveStream: DecideArchiveStream(mode, stream), defaultPassword: defaultPassword, defaultEncryption: defaultEncryption) + : this(stream, mode, leaveOpen, entryNameEncoding) { - ArgumentNullException.ThrowIfNull(stream); - - Stream? extraTempStream = null; - - try - { - _backingStream = null; - - if (ValidateMode(mode, stream)) - { - _backingStream = stream; - extraTempStream = stream = new MemoryStream(); - _backingStream.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - } - - _archiveStream = DecideArchiveStream(mode, stream); - - switch (mode) - { - case ZipArchiveMode.Create: - _readEntries = true; - break; - - case ZipArchiveMode.Read: - ReadEndOfCentralDirectory(); - break; - - case ZipArchiveMode.Update: - default: - Debug.Assert(mode == ZipArchiveMode.Update); - if (_archiveStream.Length == 0) - { - _readEntries = true; - } - else - { - ReadEndOfCentralDirectory(); - EnsureCentralDirectoryRead(); + _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; + _defaultEncryption = defaultEncryption; - foreach (ZipArchiveEntry entry in _entries) - { - entry.ThrowIfNotOpenable(needToUncompress: false, needToLoadIntoMemory: true); - } - } - break; - } - } - catch + // Validate that if encryption is specified, a password is provided, what to do otherwise? + if (_defaultEncryption != ZipArchiveEntry.EncryptionMethod.None && _defaultPassword is null) { - extraTempStream?.Dispose(); - throw; + throw new ArgumentException("A password must be provided when encryption is specified.", nameof(defaultPassword)); } } @@ -263,22 +217,6 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _firstDeletedEntryOffset = long.MaxValue; } - - private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, Stream? backingStream, Stream archiveStream, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(mode, leaveOpen, entryNameEncoding, backingStream, archiveStream) - { - _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; - _defaultEncryption = defaultEncryption; - - // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused) ?? - // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. - if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) - { - throw new ArgumentException("A default password must be provided when defaultEncryption is ZipCrypto.", nameof(defaultPassword)); - } - } - - /// /// Gets or sets the optional archive comment. /// 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 f830b0ba8a498e..be1c8a3c7e4932 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 @@ -949,20 +949,20 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); // Determine key size based on encryption method - int keySizeBits = _encryptionMethod switch - { - EncryptionMethod.Aes128 => 128, - EncryptionMethod.Aes192 => 192, - EncryptionMethod.Aes256 => 256, - _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") - }; - - // For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field - // The _crc32 field should contain the CRC value for AE-2 - bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; + //int keySizeBits = _encryptionMethod switch + //{ + // EncryptionMethod.Aes128 => 128, + // EncryptionMethod.Aes192 => 192, + // EncryptionMethod.Aes256 => 256, + // _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") + //}; + + //// For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field + //// The _crc32 field should contain the CRC value for AE-2 + //bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; // Read and parse the AES extra field to get necessary parameters - toDecompress = new AesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); + // toDecompress = new WinZipAesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); } From 623f238b473cb981bcaff016ba159cce86424d8c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 13 Nov 2025 15:48:46 +0100 Subject: [PATCH 12/83] remove storing password fields --- .../ZipFileExtensions.ZipArchive.Create.cs | 24 +- .../tests/ZipFile.Extract.cs | 952 +++++++++--------- .../ref/System.IO.Compression.cs | 5 +- .../src/System.IO.Compression.csproj | 1 - .../System/IO/Compression/WinZipAesStream.cs | 782 +++++++------- .../src/System/IO/Compression/ZipArchive.cs | 51 +- .../System/IO/Compression/ZipArchiveEntry.cs | 238 ++--- .../System/IO/Compression/ZipCryptoStream.cs | 18 +- 8 files changed, 970 insertions(+), 1101 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 1fe78d9e53f21b..f9a79d83be7df7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -79,12 +79,12 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) => - DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); + DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + string sourceFileName, string entryName, CompressionLevel? compressionLevel) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password, encryption); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); using (fs) { @@ -97,18 +97,11 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio return entry; } - private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync, - string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); - - if (!string.IsNullOrEmpty(password) && encryption == ZipArchiveEntry.EncryptionMethod.None) - { - throw new ArgumentException("password and encryption should both be set"); - } - // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. @@ -116,12 +109,9 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); - ZipArchiveEntry entry = password is not null ? (compressionLevel.HasValue - ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) - : destination.CreateEntry(entryName, password, encryption)) - : (compressionLevel.HasValue - ? destination.CreateEntry(entryName, compressionLevel.Value) - : destination.CreateEntry(entryName)); + ZipArchiveEntry entry = compressionLevel.HasValue ? + destination.CreateEntry(entryName, compressionLevel.Value) + : destination.CreateEntry(entryName); DateTime lastWrite = File.GetLastWriteTime(sourceFileName); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 0233394122f3e0..5e5b3e6a878c41 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -356,8 +356,6 @@ await Assert.ThrowsAsync(async () => }); } - - [Fact] public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() { @@ -505,9 +503,9 @@ public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { // Your custom overload that sets per-entry password & ZipCrypto - var entry = za.CreateEntry(entryName, password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName); - using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + using var writer = new StreamWriter(entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); writer.Write(expectedContent); } @@ -551,8 +549,8 @@ public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() { foreach (var it in items) { - var entry = za.CreateEntry(it.Name, password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -592,8 +590,8 @@ public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() { foreach (var it in items) { - var entry = za.CreateEntry(it.Name, it.Password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(it.Password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -651,9 +649,9 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() // Encrypted foreach (var it in encryptedItems) { - var entry = za.CreateEntry(it.Name, encPw, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(encPw, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + w.Write(it.Content); } // Plain @@ -661,7 +659,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() { var entry = za.CreateEntry(it.Name); // default: no encryption using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); + w.Write(it.Content); } } @@ -717,8 +715,8 @@ public async Task Update_AddEncryptedEntry_RoundTrip() // Act: Open in Update mode and add encrypted entry using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) { - var encEntry = za.CreateEntry("secure/new.txt", "pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(encEntry.Open(), Encoding.UTF8); + var encEntry = za.CreateEntry("secure/new.txt"); + using var w = new StreamWriter(encEntry.Open("pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); await w.WriteAsync("secret data"); } @@ -746,8 +744,8 @@ public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry("secure/delete.txt", "delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); + var e = za.CreateEntry("secure/delete.txt"); + using var w = new StreamWriter(e.Open("delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); await w.WriteAsync("to be deleted"); } @@ -776,8 +774,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() const string pw = "copy-pw"; using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); + var e = za.CreateEntry("secure/original.txt"); + using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); w.Write("original content"); } @@ -793,8 +791,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() content = r.ReadToEnd(); // Create new entry with same password - var dst = za.CreateEntry("secure/copy.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(dst.Open(), Encoding.UTF8); + var dst = za.CreateEntry("secure/copy.txt"); + using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); w.Write(content); } @@ -831,8 +829,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() // Create archive and a single encrypted entry using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry(originalName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var e = za.CreateEntry(originalName); + using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(payload); } @@ -855,8 +853,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() }); // Create the destination entry with the same password and write the copied content. - var dst = za.CreateEntry(copyName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(dst.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var dst = za.CreateEntry(copyName); + using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(content); } @@ -883,461 +881,457 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() } - [Fact] - public void Update_OpenEncryptedEntry_WrongPassword_Throws() - { - string zipPath = NewPath("update_wrong_pw.zip"); - const string pw = "correct-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - w.Write("secret"); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/file.txt"); - Assert.NotNull(e); - Assert.ThrowsAny(() => - { - using var _ = e.Open("wrong-pw"); - }); - } - } - - - [Fact] - public async Task Update_EditPlainEntry_RoundTrip() - { - string zipPath = NewPath("update_edit_plain.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("plain.txt"); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("original"); - } - - // Edit in Update mode - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("plain.txt"); - Assert.NotNull(e); - - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Verify updated content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("plain.txt"); - using var r = new StreamReader(e.Open(), Encoding.UTF8); - Assert.Equal("modified", await r.ReadToEndAsync()); - } - } - - - - [Fact] - public void Update_EditEncryptedEntryWithoutPassword_Throws() - { - string zipPath = NewPath("update_edit_encrypted.zip"); - const string pw = "edit-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - w.Write("secret"); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/edit.txt"); - Assert.NotNull(e); - - // Should throw because edit-in-place for encrypted entries is not supported - Assert.Throws(() => - { - using var _ = e.Open(); // no password - }); - } - } - - - [Fact] - public async Task Update_MixedEntries_ReadEncrypted_EditPlain() - { - string zipPath = NewPath("update_mixed.zip"); - const string pw = "mixed-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial zip with encrypted and plain entries - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) - await w.WriteAsync("encrypted"); - - var plainEntry = za.CreateEntry("plain.txt"); - using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) - await w.WriteAsync("original"); - } - - // First update: read encrypted, modify plain - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var enc = za.GetEntry("secure/data.txt"); - Assert.NotNull(enc); - - string encryptedContent; - using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - encryptedContent = await r.ReadToEndAsync(); - - var plain = za.GetEntry("plain.txt"); - using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Second update: verify encrypted, re-modify plain - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var enc = za.GetEntry("secure/data.txt"); - using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - Assert.Equal("encrypted", await r.ReadToEndAsync()); - - var plain = za.GetEntry("plain.txt"); - using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Final read: verify both entries - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) - Assert.Equal("encrypted", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) - Assert.Equal("modified", await r2.ReadToEndAsync()); - } - } - - - - [Fact] - public async Task Update_ModifySameEncryptedEntryMultipleTimes() - { - string zipPath = NewPath("update_modify_multiple.zip"); - const string pw = "multi-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("version1"); - } - - // Modify entry multiple times - for (int i = 2; i <= 3; i++) - { - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/data.txt"); - Assert.NotNull(e); - - string oldContent; - using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) - oldContent = await r.ReadToEndAsync(); - - e.Delete(); // remove old entry - - var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); - await w.WriteAsync($"{oldContent}-version{i}"); - } - } - - // Assert final content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("secure/data.txt"); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); - var text = await r.ReadToEndAsync(); - Assert.Equal("version1-version2-version3", text); - } - } - - - [Fact] - public async Task Update_CopyEncryptedEntryToPlainEntry() - { - string zipPath = NewPath("update_copy_to_plain.zip"); - const string pw = "plain-copy"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("secret content"); - } - - // Copy encrypted content to a plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry("secure/original.txt"); - Assert.NotNull(src); - - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - content = await r.ReadToEndAsync(); - - var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption - using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); - await w.WriteAsync(content); - } - - // Assert both entries exist and content matches - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var enc = za.GetEntry("secure/original.txt"); - var plain = za.GetEntry("public/copy.txt"); - Assert.NotNull(enc); - Assert.NotNull(plain); - - using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) - Assert.Equal("secret content", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) - Assert.Equal("secret content", await r2.ReadToEndAsync()); - } - } - - - - [Fact] - public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string srcPath = NewPath("source_wrong_pw.txt"); - string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); - const string entryName = "secure/wrong.txt"; - const string correctPassword = "correct!"; - const string badPassword = "wrong!"; - const string payload = "secret data"; - - if (File.Exists(srcPath)) File.Delete(srcPath); - if (File.Exists(zipPath)) File.Delete(zipPath); - - File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntryFromFile( - sourceFileName: srcPath, - entryName: entryName, - compressionLevel: CompressionLevel.Optimal, - password: correctPassword, - encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - } - - // Act & Assert - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - - Assert.ThrowsAny(() => - { - using var _ = e!.Open(badPassword); - }); - } - } - - - [Fact] - public async Task CreateEntryFromFile_WithEncryption_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string srcPath = NewPath("source_plain.txt"); - string zipPath = NewPath("create_from_file_plain.zip"); - const string entryName = "plain/copy.txt"; - const string payload = "this is plain"; - const string pwd = "anything"; - - if (File.Exists(srcPath)) File.Delete(srcPath); - if (File.Exists(zipPath)) File.Delete(zipPath); - - await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntryFromFile( - sourceFileName: srcPath, - entryName: entryName, - compressionLevel: CompressionLevel.Optimal, - password: pwd, - encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - - using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - string text = await r.ReadToEndAsync(); - Assert.Equal(payload, text); - - // Opening a plain entry with a password should throw - Assert.ThrowsAny(() => - { - using var _ = e.Open("some-password"); - }); - } - } - - - - - [Fact] - public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() - { - Directory.CreateDirectory(DownloadsDir); - var zipPath = NewPath("defaults_apply.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string defaultPassword = "archive-pw"; - const string payload = "default encryption content"; - const string entryName = "secure/default.txt"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: defaultPassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e = za.CreateEntry(entryName); - - using (var es = e.Open()) - { - var bytes = Encoding.UTF8.GetBytes(payload); - es.Write(bytes, 0, bytes.Length); - } - } - - // Verify with the archive default password - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); - Assert.Equal(payload, r.ReadToEnd()); - } - } - - [Fact] - public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() - { - string zipPath = NewPath("defaults_multiple.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string defaultPassword = "archive-pw"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: defaultPassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e1 = za.CreateEntry("secure/one.txt"); - using (var s1 = e1.Open()) - { - var b = Encoding.UTF8.GetBytes("ONE"); - s1.Write(b, 0, b.Length); - } - - var e2 = za.CreateEntry("secure/two.txt"); - using (var s2 = e2.Open()) - { - var b = Encoding.UTF8.GetBytes("TWO"); - s2.Write(b, 0, b.Length); - } - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) - Assert.Equal("ONE", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) - Assert.Equal("TWO", await r2.ReadToEndAsync()); - } - } - - [Fact] - public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() - { - string zipPath = NewPath("override_default.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string archivePassword = "archive-pw"; - const string entryPassword = "entry-pw"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: archivePassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using (var s = e.Open()) - { - var b = Encoding.UTF8.GetBytes("OVERRIDE"); - s.Write(b, 0, b.Length); - } - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("secure/override.txt"); - Assert.NotNull(e); - - // Should succeed with entry password - using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) - Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); - - // Wrong: using archive default should fail - Assert.ThrowsAny(() => - { - using var _ = e.Open(archivePassword); - }); - } - } + //[Fact] + //public void Update_OpenEncryptedEntry_WrongPassword_Throws() + //{ + // string zipPath = NewPath("update_wrong_pw.zip"); + // const string pw = "correct-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // w.Write("secret"); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/file.txt"); + // Assert.NotNull(e); + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open("wrong-pw"); + // }); + // } + //} + + + //[Fact] + //public async Task Update_EditPlainEntry_RoundTrip() + //{ + // string zipPath = NewPath("update_edit_plain.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create plain entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("plain.txt"); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("original"); + // } + + // // Edit in Update mode + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("plain.txt"); + // Assert.NotNull(e); + + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Verify updated content + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("plain.txt"); + // using var r = new StreamReader(e.Open(), Encoding.UTF8); + // Assert.Equal("modified", await r.ReadToEndAsync()); + // } + //} + + + + //[Fact] + //public void Update_EditEncryptedEntryWithoutPassword_Throws() + //{ + // string zipPath = NewPath("update_edit_encrypted.zip"); + // const string pw = "edit-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // w.Write("secret"); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/edit.txt"); + // Assert.NotNull(e); + + // // Should throw because edit-in-place for encrypted entries is not supported + // Assert.Throws(() => + // { + // using var _ = e.Open(); // no password + // }); + // } + //} + + + //[Fact] + //public async Task Update_MixedEntries_ReadEncrypted_EditPlain() + //{ + // string zipPath = NewPath("update_mixed.zip"); + // const string pw = "mixed-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create initial zip with encrypted and plain entries + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) + // await w.WriteAsync("encrypted"); + + // var plainEntry = za.CreateEntry("plain.txt"); + // using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) + // await w.WriteAsync("original"); + // } + + // // First update: read encrypted, modify plain + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var enc = za.GetEntry("secure/data.txt"); + // Assert.NotNull(enc); + + // string encryptedContent; + // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + // encryptedContent = await r.ReadToEndAsync(); + + // var plain = za.GetEntry("plain.txt"); + // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Second update: verify encrypted, re-modify plain + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var enc = za.GetEntry("secure/data.txt"); + // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + // Assert.Equal("encrypted", await r.ReadToEndAsync()); + + // var plain = za.GetEntry("plain.txt"); + // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Final read: verify both entries + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) + // Assert.Equal("encrypted", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) + // Assert.Equal("modified", await r2.ReadToEndAsync()); + // } + //} + + + + //[Fact] + //public async Task Update_ModifySameEncryptedEntryMultipleTimes() + //{ + // string zipPath = NewPath("update_modify_multiple.zip"); + // const string pw = "multi-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create initial encrypted entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("version1"); + // } + + // // Modify entry multiple times + // for (int i = 2; i <= 3; i++) + // { + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/data.txt"); + // Assert.NotNull(e); + + // string oldContent; + // using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) + // oldContent = await r.ReadToEndAsync(); + + // e.Delete(); // remove old entry + + // var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); + // await w.WriteAsync($"{oldContent}-version{i}"); + // } + // } + + // // Assert final content + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("secure/data.txt"); + // Assert.NotNull(e); + // using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); + // var text = await r.ReadToEndAsync(); + // Assert.Equal("version1-version2-version3", text); + // } + //} + + + //[Fact] + //public async Task Update_CopyEncryptedEntryToPlainEntry() + //{ + // string zipPath = NewPath("update_copy_to_plain.zip"); + // const string pw = "plain-copy"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create encrypted entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("secret content"); + // } + + // // Copy encrypted content to a plain entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var src = za.GetEntry("secure/original.txt"); + // Assert.NotNull(src); + + // string content; + // using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + // content = await r.ReadToEndAsync(); + + // var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption + // using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); + // await w.WriteAsync(content); + // } + + // // Assert both entries exist and content matches + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var enc = za.GetEntry("secure/original.txt"); + // var plain = za.GetEntry("public/copy.txt"); + // Assert.NotNull(enc); + // Assert.NotNull(plain); + + // using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) + // Assert.Equal("secret content", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) + // Assert.Equal("secret content", await r2.ReadToEndAsync()); + // } + //} + + + //[Fact] + //public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() + //{ + // // Arrange + // Directory.CreateDirectory(DownloadsDir); + // string srcPath = NewPath("source_wrong_pw.txt"); + // string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); + // const string entryName = "secure/wrong.txt"; + // const string correctPassword = "correct!"; + // const string badPassword = "wrong!"; + // const string payload = "secret data"; + + // if (File.Exists(srcPath)) File.Delete(srcPath); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntryFromFile( + // sourceFileName: srcPath, + // entryName: entryName, + // compressionLevel: CompressionLevel.Optimal, + // password: correctPassword, + // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // } + + // // Act & Assert + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + + // Assert.ThrowsAny(() => + // { + // using var _ = e!.Open(badPassword); + // }); + // } + //} + + + //[Fact] + //public async Task CreateEntryFromFile_WithEncryption_RoundTrip() + //{ + // // Arrange + // Directory.CreateDirectory(DownloadsDir); + // string srcPath = NewPath("source_plain.txt"); + // string zipPath = NewPath("create_from_file_plain.zip"); + // const string entryName = "plain/copy.txt"; + // const string payload = "this is plain"; + // const string pwd = "anything"; + + // if (File.Exists(srcPath)) File.Delete(srcPath); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntryFromFile( + // sourceFileName: srcPath, + // entryName: entryName, + // compressionLevel: CompressionLevel.Optimal, + // password: pwd, + // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + + // using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + // string text = await r.ReadToEndAsync(); + // Assert.Equal(payload, text); + + // // Opening a plain entry with a password should throw + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open("some-password"); + // }); + // } + //} + + //[Fact] + //public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() + //{ + // Directory.CreateDirectory(DownloadsDir); + // var zipPath = NewPath("defaults_apply.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string defaultPassword = "archive-pw"; + // const string payload = "default encryption content"; + // const string entryName = "secure/default.txt"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: defaultPassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e = za.CreateEntry(entryName); + + // using (var es = e.Open()) + // { + // var bytes = Encoding.UTF8.GetBytes(payload); + // es.Write(bytes, 0, bytes.Length); + // } + // } + + // // Verify with the archive default password + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + // using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); + // Assert.Equal(payload, r.ReadToEnd()); + // } + //} + + //[Fact] + //public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() + //{ + // string zipPath = NewPath("defaults_multiple.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string defaultPassword = "archive-pw"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: defaultPassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e1 = za.CreateEntry("secure/one.txt"); + // using (var s1 = e1.Open()) + // { + // var b = Encoding.UTF8.GetBytes("ONE"); + // s1.Write(b, 0, b.Length); + // } + + // var e2 = za.CreateEntry("secure/two.txt"); + // using (var s2 = e2.Open()) + // { + // var b = Encoding.UTF8.GetBytes("TWO"); + // s2.Write(b, 0, b.Length); + // } + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) + // Assert.Equal("ONE", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) + // Assert.Equal("TWO", await r2.ReadToEndAsync()); + // } + //} + + //[Fact] + //public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() + //{ + // string zipPath = NewPath("override_default.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string archivePassword = "archive-pw"; + // const string entryPassword = "entry-pw"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: archivePassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using (var s = e.Open()) + // { + // var b = Encoding.UTF8.GetBytes("OVERRIDE"); + // s.Write(b, 0, b.Length); + // } + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("secure/override.txt"); + // Assert.NotNull(e); + + // // Should succeed with entry password + // using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) + // Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); + + // // Wrong: using archive default should fail + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open(archivePassword); + // }); + // } + //} [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() 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 fe1ffe0e3030ef..c1a6360b72c4e0 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -96,7 +96,6 @@ public ZipArchive(System.IO.Stream stream) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding) { } - public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, string? defaultPassword, System.IO.Compression.ZipArchiveEntry.EncryptionMethod defaultEncryption) { } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string Comment { get { throw null; } set { } } public System.Collections.ObjectModel.ReadOnlyCollection Entries { get { throw null; } } @@ -104,8 +103,6 @@ public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode public static System.Threading.Tasks.Task CreateAsync(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } - public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } - public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } @@ -129,7 +126,7 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } - public System.IO.Stream Open(string password) { throw null; } + public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.ZipCrypto) { throw null; } public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 64799e955ab637..2c6ff2fa3a63b5 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -74,7 +74,6 @@ - diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 122dc298f01725..266d64921d4519 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -8,396 +8,394 @@ namespace System.IO.Compression { - internal sealed class WinZipAesStream : Stream - { - private readonly Stream _baseStream; - private readonly bool _encrypting; - private readonly int _keySizeBits; - private readonly bool _ae2; - private readonly uint? _crc32ForHeader; - private readonly Aes _aes; - private ICryptoTransform? _aesEncryptor; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - private readonly HMACSHA1 _hmac; -#pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[16]; - private byte[]? _key; - private byte[]? _hmacKey; - private byte[]? _salt; - private byte[]? _passwordVerifier; - private bool _headerWritten; - private bool _headerRead; - private long _position; - private readonly ReadOnlyMemory _password; - private bool _disposed; - private bool _authCodeValidated; - private readonly byte[] _authCodeBuffer = new byte[20]; // HMACSHA1 is 20 bytes - private int _authCodeBufferCount; - - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) - { - ArgumentNullException.ThrowIfNull(baseStream); - - - _baseStream = baseStream; - _password = password; - _encrypting = encrypting; - _keySizeBits = keySizeBits; - _ae2 = ae2; - _crc32ForHeader = crc32; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - _aes = Aes.Create(); -#pragma warning restore CA1416 - _aes.Mode = CipherMode.ECB; - _aes.Padding = PaddingMode.None; - -#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? - _hmac = new HMACSHA1(); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -#pragma warning restore CA1416 - - if (_encrypting) - { - GenerateKeys(); - InitCipher(); - } - } - - private void DeriveKeysFromPassword() - { - Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - - // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); - - _key = new byte[_keySizeBits / 8]; - _hmacKey = new byte[32]; - _passwordVerifier = new byte[2]; - - Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); - Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); - Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); - } - - private void GenerateKeys() - { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - } - - private void InitCipher() - { - Debug.Assert(_key is not null, "_key is not null"); - - _aes.Key = _key!; - _aesEncryptor = _aes.CreateEncryptor(); - } - - private void WriteHeader() - { - if (_headerWritten) return; - - Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - - _baseStream.Write(_salt); - _baseStream.Write(_passwordVerifier); - - if (_ae2 && _crc32ForHeader.HasValue) - { - Span crcBytes = stackalloc byte[4]; - BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); - _baseStream.Write(crcBytes); - } - - _headerWritten = true; - } - - private void ReadHeader() - { - if (_headerRead) return; - - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - DeriveKeysFromPassword(); - - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - throw new InvalidDataException("Invalid password."); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - if (_ae2) - { - byte[] crcBytes = new byte[4]; - _baseStream.ReadExactly(crcBytes); - // CRC can be validated later if needed - } - - _headerRead = true; - } - - private void ProcessBlock(byte[] buffer, int offset, int count) - { - Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); - - int processed = 0; - byte[] keystream = new byte[16]; - while (processed < count) - { - IncrementCounter(); - _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - - // For the last block, we may use less than 16 bytes of the keystream - // This is correct CTR mode behavior - we only use as many bytes as needed - int blockSize = Math.Min(16, count - processed); - - // XOR the data with the keystream - // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream - // The unused bytes are discarded, which is the expected - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } - - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); - processed += blockSize; - } - } - - private void IncrementCounter() - { - for (int i = 15; i >= 0; i--) - { - if (++_counterBlock[i] != 0) break; - } - } - - private void WriteAuthCode() - { - if (!_encrypting || _authCodeValidated) - return; - - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; - - if (authCode is not null) - { - _baseStream.Write(authCode); - } - - _authCodeValidated = true; - } - - private void ValidateAuthCode() - { - if (_encrypting || _authCodeValidated) - return; - - // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; - - if (expectedAuth is not null) - { - // Read the stored authentication code from the stream - byte[] storedAuth = new byte[expectedAuth.Length]; - _baseStream.ReadExactly(storedAuth); - - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) - throw new InvalidDataException("Authentication code mismatch."); - } - - _authCodeValidated = true; - } - - private void WriteCore(ReadOnlySpan buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - WriteHeader(); - - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); - _position += buffer.Length; - } - - private int ReadCore(Span buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); - - if (!_headerRead) - ReadHeader(); - - int n = _baseStream.Read(buffer); - - // Check if we reached the end of the stream - if (n == 0 && !_authCodeValidated) - { - ValidateAuthCode(); - return 0; - } - - if (n > 0) - { - // Process the data in-place for reads (it's already in the buffer) - // We need to temporarily copy to array for HMAC processing - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer); - _position += n; - } - - return n; - } - - // All Write overloads redirect to Write(ReadOnlySpan) - public override void Write(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); - } - - public override void Write(ReadOnlySpan buffer) - { - WriteCore(buffer); - } - - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - return Core(buffer, cancellationToken); - - async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - WriteHeader(); - - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += buffer.Length; - } - } - - public override int Read(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - return ReadCore(new Span(buffer, offset, count)); - } - - public override int Read(Span buffer) - { - return ReadCore(buffer); - } - - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); - - if (!_headerRead) - ReadHeader(); - - int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (n > 0) - { - // Process the data - work with the Memory span - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer.Span); - _position += n; - } - - return n; - } - - protected override void Dispose(bool disposing) - { - if (_disposed) - return; - - if (disposing) - { - try - { - // For encryption, write the auth code when closing - if (_encrypting && _headerWritten && !_authCodeValidated) - { - WriteAuthCode(); - } - - _baseStream.Flush(); - } - finally - { - _aesEncryptor?.Dispose(); - _aes.Dispose(); - _hmac.Dispose(); - } - } - - _disposed = true; - base.Dispose(disposing); - } - - public override bool CanRead => !_encrypting && !_disposed; - public override bool CanSeek => false; - public override bool CanWrite => _encrypting && !_disposed; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => _position; - set => throw new NotSupportedException(); - } - - public override void Flush() - { - ObjectDisposedException.ThrowIf(_disposed, this); - _baseStream.Flush(); - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - } +// internal sealed class WinZipAesStream : Stream +// { +// private readonly Stream _baseStream; +// private readonly bool _encrypting; +// private readonly int _keySizeBits; +// private readonly bool _ae2; +// private readonly uint? _crc32ForHeader; +// private readonly Aes _aes; +// private ICryptoTransform? _aesEncryptor; +//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms +// private readonly HMACSHA1 _hmac; +//#pragma warning restore CA1416 +// private readonly byte[] _counterBlock = new byte[16]; +// private byte[]? _key; +// private byte[]? _hmacKey; +// private byte[]? _salt; +// private byte[]? _passwordVerifier; +// private bool _headerWritten; +// private bool _headerRead; +// private long _position; +// private readonly ReadOnlyMemory _password; +// private bool _disposed; +// private bool _authCodeValidated; + +// public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) +// { +// ArgumentNullException.ThrowIfNull(baseStream); + + +// _baseStream = baseStream; +// _password = password; +// _encrypting = encrypting; +// _keySizeBits = keySizeBits; +// _ae2 = ae2; +// _crc32ForHeader = crc32; +//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms +// _aes = Aes.Create(); +//#pragma warning restore CA1416 +// _aes.Mode = CipherMode.ECB; +// _aes.Padding = PaddingMode.None; + +//#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +//#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? +// _hmac = new HMACSHA1(); +//#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +//#pragma warning restore CA1416 + +// if (_encrypting) +// { +// GenerateKeys(); +// InitCipher(); +// } +// } + +// private void DeriveKeysFromPassword() +// { +// Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); + +// // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec +// byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + +// _key = new byte[_keySizeBits / 8]; +// _hmacKey = new byte[32]; +// _passwordVerifier = new byte[2]; + +// Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); +// Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); +// Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); +// } + +// private void GenerateKeys() +// { +// // 8 for AES-128, 12 for AES-192, 16 for AES-256 +// int saltSize = _keySizeBits / 16; +// _salt = new byte[saltSize]; +// RandomNumberGenerator.Fill(_salt); + +// DeriveKeysFromPassword(); + +// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); +// _hmac.Key = _hmacKey!; +// } + +// private void InitCipher() +// { +// Debug.Assert(_key is not null, "_key is not null"); + +// _aes.Key = _key!; +// _aesEncryptor = _aes.CreateEncryptor(); +// } + +// private void WriteHeader() +// { +// if (_headerWritten) return; + +// Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + +// _baseStream.Write(_salt); +// _baseStream.Write(_passwordVerifier); + +// if (_ae2 && _crc32ForHeader.HasValue) +// { +// Span crcBytes = stackalloc byte[4]; +// BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); +// _baseStream.Write(crcBytes); +// } + +// _headerWritten = true; +// } + +// private void ReadHeader() +// { +// if (_headerRead) return; + +// int saltSize = _keySizeBits / 16; +// _salt = new byte[saltSize]; +// _baseStream.ReadExactly(_salt); + +// byte[] verifier = new byte[2]; +// _baseStream.ReadExactly(verifier); + +// DeriveKeysFromPassword(); + +// Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); +// if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) +// throw new InvalidDataException("Invalid password."); + +// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); +// _hmac.Key = _hmacKey!; +// InitCipher(); + +// if (_ae2) +// { +// byte[] crcBytes = new byte[4]; +// _baseStream.ReadExactly(crcBytes); +// // CRC can be validated later if needed +// } + +// _headerRead = true; +// } + +// private void ProcessBlock(byte[] buffer, int offset, int count) +// { +// Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + +// int processed = 0; +// byte[] keystream = new byte[16]; +// while (processed < count) +// { +// IncrementCounter(); +// _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + +// // For the last block, we may use less than 16 bytes of the keystream +// // This is correct CTR mode behavior - we only use as many bytes as needed +// int blockSize = Math.Min(16, count - processed); + +// // XOR the data with the keystream +// // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream +// // The unused bytes are discarded, which is the expected +// for (int i = 0; i < blockSize; i++) +// { +// buffer[offset + processed + i] ^= keystream[i]; +// } + +// _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); +// processed += blockSize; +// } +// } + +// private void IncrementCounter() +// { +// for (int i = 15; i >= 0; i--) +// { +// if (++_counterBlock[i] != 0) break; +// } +// } + +// private void WriteAuthCode() +// { +// if (!_encrypting || _authCodeValidated) +// return; + +// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); +// byte[]? authCode = _hmac.Hash; + +// if (authCode is not null) +// { +// _baseStream.Write(authCode); +// } + +// _authCodeValidated = true; +// } + +// private void ValidateAuthCode() +// { +// if (_encrypting || _authCodeValidated) +// return; + +// // Finalize HMAC computation +// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); +// byte[]? expectedAuth = _hmac.Hash; + +// if (expectedAuth is not null) +// { +// // Read the stored authentication code from the stream +// byte[] storedAuth = new byte[expectedAuth.Length]; +// _baseStream.ReadExactly(storedAuth); + +// if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) +// throw new InvalidDataException("Authentication code mismatch."); +// } + +// _authCodeValidated = true; +// } + +// private void WriteCore(ReadOnlySpan buffer) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (!_encrypting) +// throw new NotSupportedException("Stream is in decryption mode."); + +// WriteHeader(); + +// // We need to copy the data since ProcessBlock modifies it in place +// byte[] tmp = buffer.ToArray(); +// ProcessBlock(tmp, 0, tmp.Length); +// _baseStream.Write(tmp); +// _position += buffer.Length; +// } + +// private int ReadCore(Span buffer) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (_encrypting) +// throw new NotSupportedException("Stream is in encryption mode."); + +// if (!_headerRead) +// ReadHeader(); + +// int n = _baseStream.Read(buffer); + +// // Check if we reached the end of the stream +// if (n == 0 && !_authCodeValidated) +// { +// ValidateAuthCode(); +// return 0; +// } + +// if (n > 0) +// { +// // Process the data in-place for reads (it's already in the buffer) +// // We need to temporarily copy to array for HMAC processing +// byte[] temp = buffer.Slice(0, n).ToArray(); +// ProcessBlock(temp, 0, n); +// temp.CopyTo(buffer); +// _position += n; +// } + +// return n; +// } + +// // All Write overloads redirect to Write(ReadOnlySpan) +// public override void Write(byte[] buffer, int offset, int count) +// { +// ValidateBufferArguments(buffer, offset, count); +// Write(new ReadOnlySpan(buffer, offset, count)); +// } + +// public override void Write(ReadOnlySpan buffer) +// { +// WriteCore(buffer); +// } + +// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) +// { +// ValidateBufferArguments(buffer, offset, count); +// await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); +// } + +// public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (!_encrypting) +// throw new NotSupportedException("Stream is in decryption mode."); + +// return Core(buffer, cancellationToken); + +// async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) +// { +// WriteHeader(); + +// // We need to copy the data since ProcessBlock modifies it in place +// byte[] tmp = buffer.ToArray(); +// ProcessBlock(tmp, 0, tmp.Length); +// await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); +// _position += buffer.Length; +// } +// } + +// public override int Read(byte[] buffer, int offset, int count) +// { +// ValidateBufferArguments(buffer, offset, count); +// return ReadCore(new Span(buffer, offset, count)); +// } + +// public override int Read(Span buffer) +// { +// return ReadCore(buffer); +// } + +// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) +// { +// ValidateBufferArguments(buffer, offset, count); +// return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); +// } + +// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (_encrypting) +// throw new NotSupportedException("Stream is in encryption mode."); + +// if (!_headerRead) +// ReadHeader(); + +// int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); +// if (n > 0) +// { +// // Process the data - work with the Memory span +// byte[] temp = buffer.Slice(0, n).ToArray(); +// ProcessBlock(temp, 0, n); +// temp.CopyTo(buffer.Span); +// _position += n; +// } + +// return n; +// } + +// protected override void Dispose(bool disposing) +// { +// if (_disposed) +// return; + +// if (disposing) +// { +// try +// { +// // For encryption, write the auth code when closing +// if (_encrypting && _headerWritten && !_authCodeValidated) +// { +// WriteAuthCode(); +// } + +// _baseStream.Flush(); +// } +// finally +// { +// _aesEncryptor?.Dispose(); +// _aes.Dispose(); +// _hmac.Dispose(); +// } +// } + +// _disposed = true; +// base.Dispose(disposing); +// } + +// public override bool CanRead => !_encrypting && !_disposed; +// public override bool CanSeek => false; +// public override bool CanWrite => _encrypting && !_disposed; +// public override long Length => throw new NotSupportedException(); +// public override long Position +// { +// get => _position; +// set => throw new NotSupportedException(); +// } + +// public override void Flush() +// { +// ObjectDisposedException.ThrowIf(_disposed, this); +// _baseStream.Flush(); +// } + +// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); +// public override void SetLength(long value) => throw new NotSupportedException(); +// } } 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 bc16f2119bbf88..1133482cd6f446 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 @@ -9,7 +9,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Text; namespace System.IO.Compression @@ -32,10 +31,6 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; - private readonly string? _defaultPassword; - private readonly ZipArchiveEntry.EncryptionMethod _defaultEncryption; - - #if DEBUG_FORCE_ZIP64 public bool _forceZip64; @@ -180,20 +175,6 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } - public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(stream, mode, leaveOpen, entryNameEncoding) - { - _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; - _defaultEncryption = defaultEncryption; - - // Validate that if encryption is specified, a password is provided, what to do otherwise? - if (_defaultEncryption != ZipArchiveEntry.EncryptionMethod.None && _defaultPassword is null) - { - throw new ArgumentException("A password must be provided when encryption is specified.", nameof(defaultPassword)); - } - } - - /// Helper constructor that initializes some of the essential ZipArchive /// information that other constructors initialize the same way. /// Validations, checks and entry collection need to be done outside this constructor. @@ -286,11 +267,6 @@ public ZipArchiveEntry CreateEntry(string entryName) return DoCreateEntry(entryName, null); } - public ZipArchiveEntry CreateEntry(string entryName, string password, ZipArchiveEntry.EncryptionMethod encryption) - { - return DoCreateEntry(entryName, null, password, encryption); - } - /// /// Creates an empty entry in the Zip archive with the specified entry name. There are no restrictions on the names of entries. The last write time of the entry is set to the current time. If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name. /// @@ -306,11 +282,6 @@ public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressio return DoCreateEntry(entryName, compressionLevel); } - public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) - { - return DoCreateEntry(entryName, compressionLevel, password, encryption); - } - /// /// Releases the unmanaged resources used by ZipArchive and optionally finishes writing the archive and releases the managed resources. /// @@ -372,9 +343,6 @@ protected virtual void Dispose(bool disposing) internal uint NumberOfThisDisk => _numberOfThisDisk; - internal string? DefaultPassword => _defaultPassword; - internal ZipArchiveEntry.EncryptionMethod DefaultEncryption => _defaultEncryption; - internal Encoding? EntryNameAndCommentEncoding { get => _entryNameAndCommentEncoding; @@ -411,8 +379,7 @@ private set // New entries in the archive won't change its state. internal ChangeState Changed { get; private set; } - private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel, - string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel) { ArgumentException.ThrowIfNullOrEmpty(entryName); @@ -421,20 +388,10 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ThrowIfDisposed(); + ZipArchiveEntry entry = compressionLevel.HasValue ? + new ZipArchiveEntry(this, entryName, compressionLevel.Value) : + new ZipArchiveEntry(this, entryName); - ZipArchiveEntry entry; - if (compressionLevel.HasValue) - { - entry = !string.IsNullOrEmpty(password) - ? new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption) - : new ZipArchiveEntry(this, entryName, compressionLevel.Value); - } - else - { - entry = !string.IsNullOrEmpty(password) - ? new ZipArchiveEntry(this, entryName, password, encryption) - : new ZipArchiveEntry(this, entryName); - } AddEntry(entry); return entry; 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 be1c8a3c7e4932..dc9493e35bdf25 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 @@ -47,9 +47,8 @@ public partial class ZipArchiveEntry private List? _lhUnknownExtraFields; private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; - private readonly CompressionLevel _compressionLevel; - private string? _password; private EncryptionMethod _encryptionMethod; + private readonly CompressionLevel _compressionLevel; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -99,8 +98,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -113,8 +110,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel CompressionMethod = CompressionMethodValues.Stored; } _generalPurposeBitFlag = MapDeflateCompressionOption(_generalPurposeBitFlag, _compressionLevel, CompressionMethod); - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry. @@ -167,60 +162,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; - } - - internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) - : this(archive, entryName, compressionLevel) - { - _password = password; - _encryptionMethod = encryptionMethod; - _generalPurposeBitFlag = 0; - - if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) - { - _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; - _isEncrypted = true; - } - else - { - _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; - _isEncrypted = false; - } - } - - internal ZipArchiveEntry(ZipArchive archive, string entryName, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) - : this(archive, entryName) - { - _password = password; - _encryptionMethod = encryptionMethod; - _generalPurposeBitFlag = 0; - - if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) - { - _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; - _isEncrypted = true; - } - else - { - _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; - _isEncrypted = false; - } - } - - internal string Password - { - get => _password ?? string.Empty; - set => _password = value; - } - - internal EncryptionMethod Encryption - { - get => _encryptionMethod; - set => _encryptionMethod = value; } /// @@ -412,12 +353,10 @@ public Stream Open() { ThrowIfInvalidArchive(); - bool isEncrypted = !string.IsNullOrEmpty(_archive.DefaultPassword) && _archive.DefaultEncryption != EncryptionMethod.None; - switch (_archive.Mode) { case ZipArchiveMode.Read: - return isEncrypted ? OpenInReadMode(checkOpenable: true, _archive.DefaultPassword.AsMemory()) : OpenInReadMode(checkOpenable: true); + return OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: return OpenInWriteMode(); case ZipArchiveMode.Update: @@ -434,7 +373,7 @@ public Stream Open() /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(string password) + public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) { ThrowIfInvalidArchive(); switch (_archive.Mode) @@ -446,7 +385,7 @@ public Stream Open(string password) } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: - return OpenInWriteMode(); + return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -485,7 +424,7 @@ private string DecodeEntryString(byte[] entryStringBytes) internal bool EverOpenedForWrite => _everOpenedForWrite; - internal long GetOffsetOfCompressedData() + internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_storedOffsetOfCompressedData == null) { @@ -511,7 +450,7 @@ internal long GetOffsetOfCompressedData() baseOffset = _archive.ArchiveStream.Position; // Adjust for AES salt + password verifier using _encryptionMethod - int saltSize = _encryptionMethod switch + int saltSize = encryptionMethod switch { EncryptionMethod.Aes128 => 8, EncryptionMethod.Aes192 => 12, @@ -838,55 +777,34 @@ private void DetectEntryNameVersion() } } - private CheckSumAndSizeWriteStream GetDataCompressor( - Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + + private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { - // final chain: backingStream <- Encryption <- Deflate/Stored <- CheckSumAndSizeWriteStream + // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. + // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header + // Deflate64 is not supported on all platforms Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - || CompressionMethod == CompressionMethodValues.Stored - || CompressionMethod == CompressionMethodValues.Deflate64); + || CompressionMethod == CompressionMethodValues.Stored); - string? pwd = string.IsNullOrEmpty(_password) ? _archive.DefaultPassword : _password; - - // Build encrypting layer if needed. Header will be emitted on the first write. - Stream targetSink = backingStream; - if (IsZipCryptoEncrypted() || _archive.DefaultEncryption == EncryptionMethod.ZipCrypto) - { - if (string.IsNullOrEmpty(pwd)) - throw new InvalidOperationException("Encrypted entry requires a non-empty password."); - - // For streaming (GPBF bit 3 set in LOCAL HEADER), verifier = DOS time low word of that header - ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - - targetSink = new ZipCryptoStream( - baseStream: backingStream, - password: pwd.AsMemory(), - passwordVerifierLow2Bytes: verifierLow2Bytes, - crc32: null, - leaveOpen: leaveBackingStreamOpen); - } - Stream compressorStream; bool isIntermediateStream = true; - + Stream compressorStream; switch (CompressionMethod) { case CompressionMethodValues.Stored: - compressorStream = targetSink; - isIntermediateStream = IsEncrypted; // only intermediate if we added encryption + compressorStream = backingStream; + isIntermediateStream = false; break; - case CompressionMethodValues.Deflate: case CompressionMethodValues.Deflate64: default: - compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); - isIntermediateStream = true; + compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); break; - } + } bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - var checkSumStream = new CheckSumAndSizeWriteStream( compressorStream, backingStream, @@ -895,13 +813,9 @@ private CheckSumAndSizeWriteStream GetDataCompressor( onClose, (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => { - // CRC over plaintext: thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - - long rawCompressed = backing.Position - initialPosition; - - thisRef._compressedSize = rawCompressed; + thisRef._compressedSize = backing.Position - initialPosition; closeHandler?.Invoke(thisRef, EventArgs.Empty); }); @@ -930,50 +844,16 @@ private bool IsAesEncrypted() return _storedCompressionMethod == CompressionMethodValues.Aes; } - private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) + private Stream GetDataDecompressor(Stream compressedStreamToRead) { - - Stream toDecompress = compressedStreamToRead; - if (IsZipCryptoEncrypted()) - { - if (password.IsEmpty) - throw new InvalidDataException("Password required for encrypted ZIP entry."); - - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - - toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); - } - else if (IsAesEncrypted()) - { - if (password.IsEmpty) - throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); - - // Determine key size based on encryption method - //int keySizeBits = _encryptionMethod switch - //{ - // EncryptionMethod.Aes128 => 128, - // EncryptionMethod.Aes192 => 192, - // EncryptionMethod.Aes256 => 256, - // _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") - //}; - - //// For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field - //// The _crc32 field should contain the CRC value for AE-2 - //bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; - - // Read and parse the AES extra field to get necessary parameters - // toDecompress = new WinZipAesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); - } - - Stream? uncompressedStream; switch (CompressionMethod) { case CompressionMethodValues.Deflate: - uncompressedStream = new DeflateStream(toDecompress, CompressionMode.Decompress, _uncompressedSize); + uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize); break; case CompressionMethodValues.Deflate64: - uncompressedStream = new DeflateManagedStream(toDecompress, CompressionMethodValues.Deflate64, _uncompressedSize); + uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: default: @@ -981,7 +861,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory // IsOpenable is checked before this function is called Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); - uncompressedStream = toDecompress; + uncompressedStream = compressedStreamToRead; break; } @@ -998,10 +878,29 @@ private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - return GetDataDecompressor(compressedStream, password); + Stream streamToDecompress = compressedStream; + + if (IsZipCryptoEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for encrypted ZIP entry."); + + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); + } + else if (IsAesEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + + // AES implementation placeholder as indicated in the original code + // When AES is implemented, create the appropriate decryption stream here + throw new NotSupportedException("AES encryption is not yet supported."); + } + return GetDataDecompressor(streamToDecompress); } - private WrappedStream OpenInWriteMode() + private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_everOpenedForWrite) throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); @@ -1011,17 +910,43 @@ private WrappedStream OpenInWriteMode() _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; - CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => + + // Build the stream stack with encryption if needed + Stream targetStream = _archive.ArchiveStream; + + if (encryptionMethod == EncryptionMethod.ZipCrypto) { - // release the archive stream - var entry = (ZipArchiveEntry)o!; - entry._archive.ReleaseArchiveStream(entry); - entry._outstandingWriteStream = null; - }); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); + if (string.IsNullOrEmpty(password)) + throw new InvalidOperationException("Password is required for encryption."); + + Encryption = encryptionMethod; + + // For ZipCrypto with streaming (bit 3 set), verifier = DOS time low word + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + targetStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + password: password.AsMemory(), + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true); + } + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( + targetStream, + true, + (object? o, EventArgs e) => + { + // release the archive stream + var entry = (ZipArchiveEntry)o!; + entry._archive.ReleaseArchiveStream(entry); + entry._outstandingWriteStream = null; + }); + + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } + private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) @@ -1045,7 +970,6 @@ private WrappedStream OpenInUpdateMode() }); } - private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1074,10 +998,9 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st return false; } - // Save encryption info for later use if (aesStrength.HasValue) { - _encryptionMethod = aesStrength switch + _ = aesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, @@ -1216,6 +1139,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; + internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { @@ -1655,10 +1579,11 @@ private sealed class DirectToArchiveWriterStream : Stream private readonly ZipArchiveEntry _entry; private bool _usedZip64inLH; private bool _canWrite; + private readonly EncryptionMethod _encryption; // makes the assumption that somewhere down the line, crcSizeStream is eventually writing directly to the archive // this class calls other functions on ZipArchiveEntry that write directly to the archive - public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry) + public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, EncryptionMethod encryptionMethod = EncryptionMethod.None) { _position = 0; _crcSizeStream = crcSizeStream; @@ -1667,6 +1592,7 @@ public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, Zip _entry = entry; _usedZip64inLH = false; _canWrite = true; + _encryption = encryptionMethod; } public override long Length diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index a3589e210ebec0..990fe702247a61 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -9,9 +9,9 @@ internal sealed class ZipCryptoStream : Stream private readonly Stream _base; private readonly bool _leaveOpen; private bool _headerWritten; - private bool _everWrotePayload; private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) + private int _position; private uint _key0; private uint _key1; @@ -38,6 +38,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex InitKeysFromBytes(password.Span); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes + _position = 0; } // Encryption constructor @@ -52,7 +53,7 @@ public ZipCryptoStream(Stream baseStream, _leaveOpen = leaveOpen; _verifierLow2Bytes = passwordVerifierLow2Bytes; _crc32ForHeader = crc32; - + _position = 0; InitKeysFromBytes(password.Span); } @@ -92,6 +93,7 @@ private void EnsureHeader() _base.Write(hdrCiph, 0, 12); _headerWritten = true; + _position += 12; } private void InitKeysFromBytes(ReadOnlySpan password) @@ -152,7 +154,11 @@ private byte DecryptByte(byte ciph) public override bool CanSeek => false; public override bool CanWrite => _encrypting; public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override long Position + { + get => _position; + set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); + } public override void Flush() => _base.Flush(); public override int Read(byte[] buffer, int offset, int count) @@ -166,6 +172,7 @@ public override int Read(byte[] buffer, int offset, int count) int n = _base.Read(buffer, offset, count); for (int i = 0; i < n; i++) buffer[offset + i] = DecryptByte(buffer[offset + i]); + _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -178,6 +185,7 @@ public override int Read(Span destination) int n = _base.Read(destination); for (int i = 0; i < n; i++) destination[i] = DecryptByte(destination[i]); + _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -194,7 +202,6 @@ public override void Write(byte[] buffer, int offset, int count) throw new ArgumentOutOfRangeException(); EnsureHeader(); - _everWrotePayload = _everWrotePayload || (count > 0); // Simple buffer; optimize with ArrayPool if needed later byte[] tmp = new byte[count]; @@ -206,6 +213,7 @@ public override void Write(byte[] buffer, int offset, int count) UpdateKeys(p); } _base.Write(tmp, 0, count); + _position += count; } public override void Write(ReadOnlySpan buffer) @@ -213,7 +221,6 @@ public override void Write(ReadOnlySpan buffer) if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); EnsureHeader(); - _everWrotePayload = _everWrotePayload || (buffer.Length > 0); byte[] tmp = new byte[buffer.Length]; for (int i = 0; i < buffer.Length; i++) @@ -224,6 +231,7 @@ public override void Write(ReadOnlySpan buffer) UpdateKeys(p); } _base.Write(tmp, 0, tmp.Length); + _position += tmp.Length; } protected override void Dispose(bool disposing) From b61b37c501b017791eab7faf7c93da139860df2d Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 20 Nov 2025 09:36:40 +0100 Subject: [PATCH 13/83] initial reading of winzip encrypted files --- .../src/System.IO.Compression.ZipFile.csproj | 27 +- .../tests/ZipFile.Extract.cs | 57 +- .../src/System.IO.Compression.csproj | 1 + .../System/IO/Compression/WinZipAesStream.cs | 961 +++++++++++------- .../System/IO/Compression/ZipArchiveEntry.cs | 71 +- .../src/System/IO/Compression/ZipBlocks.cs | 6 +- .../src/System.Security.Cryptography.csproj | 1 - 7 files changed, 687 insertions(+), 437 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj index 1709589cc4d076..beeaedeba67337 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj @@ -17,18 +17,14 @@ - - - + + + - + @@ -37,16 +33,11 @@ - - - - - + + + + + diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 5e5b3e6a878c41..c8e445f4988816 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1334,9 +1334,9 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() //} [Fact] - public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() { - string zipPath = Path.Join(DownloadsDir, "plainwr.zip"); + string zipPath = Path.Join(DownloadsDir, "source_plain_winzip.zip"); using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); @@ -1347,6 +1347,59 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() Assert.Equal("this is plain", content); } + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() + { + string zipPath = Path.Join(DownloadsDir, "source_plain.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() + { + string zipPath = Path.Join(DownloadsDir, "source_plain7z.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + + [Fact] + public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() + { + string zipPath = Path.Join(DownloadsDir, "multiple_entries_aes.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + // Test first entry + var entry1 = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using (var stream1 = entry1.Open("123456789")) + using (var reader1 = new StreamReader(stream1)) + { + string content1 = reader1.ReadToEnd(); + Assert.Equal("this is plain", content1); + } + + // Test second entry + var entry2 = archive.Entries.First(e => e.FullName.EndsWith("source_plain_2.txt")); + using (var stream2 = entry2.Open("123456789")) + using (var reader2 = new StreamReader(stream2)) + { + string content2 = reader2.ReadToEnd(); + Assert.Equal("this is plain", content2); + } + } + } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 2c6ff2fa3a63b5..6715dc201493bc 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -76,6 +76,7 @@ + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 266d64921d4519..7b6876fb972e14 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -3,399 +3,580 @@ using System.Diagnostics; using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; namespace System.IO.Compression { -// internal sealed class WinZipAesStream : Stream -// { -// private readonly Stream _baseStream; -// private readonly bool _encrypting; -// private readonly int _keySizeBits; -// private readonly bool _ae2; -// private readonly uint? _crc32ForHeader; -// private readonly Aes _aes; -// private ICryptoTransform? _aesEncryptor; -//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms -// private readonly HMACSHA1 _hmac; -//#pragma warning restore CA1416 -// private readonly byte[] _counterBlock = new byte[16]; -// private byte[]? _key; -// private byte[]? _hmacKey; -// private byte[]? _salt; -// private byte[]? _passwordVerifier; -// private bool _headerWritten; -// private bool _headerRead; -// private long _position; -// private readonly ReadOnlyMemory _password; -// private bool _disposed; -// private bool _authCodeValidated; - -// public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) -// { -// ArgumentNullException.ThrowIfNull(baseStream); - - -// _baseStream = baseStream; -// _password = password; -// _encrypting = encrypting; -// _keySizeBits = keySizeBits; -// _ae2 = ae2; -// _crc32ForHeader = crc32; -//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms -// _aes = Aes.Create(); -//#pragma warning restore CA1416 -// _aes.Mode = CipherMode.ECB; -// _aes.Padding = PaddingMode.None; - -//#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -//#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? -// _hmac = new HMACSHA1(); -//#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -//#pragma warning restore CA1416 - -// if (_encrypting) -// { -// GenerateKeys(); -// InitCipher(); -// } -// } - -// private void DeriveKeysFromPassword() -// { -// Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - -// // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec -// byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); - -// _key = new byte[_keySizeBits / 8]; -// _hmacKey = new byte[32]; -// _passwordVerifier = new byte[2]; - -// Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); -// Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); -// Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); -// } - -// private void GenerateKeys() -// { -// // 8 for AES-128, 12 for AES-192, 16 for AES-256 -// int saltSize = _keySizeBits / 16; -// _salt = new byte[saltSize]; -// RandomNumberGenerator.Fill(_salt); - -// DeriveKeysFromPassword(); - -// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); -// _hmac.Key = _hmacKey!; -// } - -// private void InitCipher() -// { -// Debug.Assert(_key is not null, "_key is not null"); - -// _aes.Key = _key!; -// _aesEncryptor = _aes.CreateEncryptor(); -// } - -// private void WriteHeader() -// { -// if (_headerWritten) return; - -// Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - -// _baseStream.Write(_salt); -// _baseStream.Write(_passwordVerifier); - -// if (_ae2 && _crc32ForHeader.HasValue) -// { -// Span crcBytes = stackalloc byte[4]; -// BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); -// _baseStream.Write(crcBytes); -// } - -// _headerWritten = true; -// } - -// private void ReadHeader() -// { -// if (_headerRead) return; - -// int saltSize = _keySizeBits / 16; -// _salt = new byte[saltSize]; -// _baseStream.ReadExactly(_salt); - -// byte[] verifier = new byte[2]; -// _baseStream.ReadExactly(verifier); - -// DeriveKeysFromPassword(); - -// Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); -// if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) -// throw new InvalidDataException("Invalid password."); - -// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); -// _hmac.Key = _hmacKey!; -// InitCipher(); - -// if (_ae2) -// { -// byte[] crcBytes = new byte[4]; -// _baseStream.ReadExactly(crcBytes); -// // CRC can be validated later if needed -// } - -// _headerRead = true; -// } - -// private void ProcessBlock(byte[] buffer, int offset, int count) -// { -// Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); - -// int processed = 0; -// byte[] keystream = new byte[16]; -// while (processed < count) -// { -// IncrementCounter(); -// _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - -// // For the last block, we may use less than 16 bytes of the keystream -// // This is correct CTR mode behavior - we only use as many bytes as needed -// int blockSize = Math.Min(16, count - processed); - -// // XOR the data with the keystream -// // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream -// // The unused bytes are discarded, which is the expected -// for (int i = 0; i < blockSize; i++) -// { -// buffer[offset + processed + i] ^= keystream[i]; -// } - -// _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); -// processed += blockSize; -// } -// } - -// private void IncrementCounter() -// { -// for (int i = 15; i >= 0; i--) -// { -// if (++_counterBlock[i] != 0) break; -// } -// } - -// private void WriteAuthCode() -// { -// if (!_encrypting || _authCodeValidated) -// return; - -// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); -// byte[]? authCode = _hmac.Hash; - -// if (authCode is not null) -// { -// _baseStream.Write(authCode); -// } - -// _authCodeValidated = true; -// } - -// private void ValidateAuthCode() -// { -// if (_encrypting || _authCodeValidated) -// return; - -// // Finalize HMAC computation -// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); -// byte[]? expectedAuth = _hmac.Hash; - -// if (expectedAuth is not null) -// { -// // Read the stored authentication code from the stream -// byte[] storedAuth = new byte[expectedAuth.Length]; -// _baseStream.ReadExactly(storedAuth); - -// if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) -// throw new InvalidDataException("Authentication code mismatch."); -// } - -// _authCodeValidated = true; -// } - -// private void WriteCore(ReadOnlySpan buffer) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (!_encrypting) -// throw new NotSupportedException("Stream is in decryption mode."); - -// WriteHeader(); - -// // We need to copy the data since ProcessBlock modifies it in place -// byte[] tmp = buffer.ToArray(); -// ProcessBlock(tmp, 0, tmp.Length); -// _baseStream.Write(tmp); -// _position += buffer.Length; -// } - -// private int ReadCore(Span buffer) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (_encrypting) -// throw new NotSupportedException("Stream is in encryption mode."); - -// if (!_headerRead) -// ReadHeader(); - -// int n = _baseStream.Read(buffer); - -// // Check if we reached the end of the stream -// if (n == 0 && !_authCodeValidated) -// { -// ValidateAuthCode(); -// return 0; -// } - -// if (n > 0) -// { -// // Process the data in-place for reads (it's already in the buffer) -// // We need to temporarily copy to array for HMAC processing -// byte[] temp = buffer.Slice(0, n).ToArray(); -// ProcessBlock(temp, 0, n); -// temp.CopyTo(buffer); -// _position += n; -// } - -// return n; -// } - -// // All Write overloads redirect to Write(ReadOnlySpan) -// public override void Write(byte[] buffer, int offset, int count) -// { -// ValidateBufferArguments(buffer, offset, count); -// Write(new ReadOnlySpan(buffer, offset, count)); -// } - -// public override void Write(ReadOnlySpan buffer) -// { -// WriteCore(buffer); -// } - -// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) -// { -// ValidateBufferArguments(buffer, offset, count); -// await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); -// } - -// public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (!_encrypting) -// throw new NotSupportedException("Stream is in decryption mode."); - -// return Core(buffer, cancellationToken); - -// async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) -// { -// WriteHeader(); - -// // We need to copy the data since ProcessBlock modifies it in place -// byte[] tmp = buffer.ToArray(); -// ProcessBlock(tmp, 0, tmp.Length); -// await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); -// _position += buffer.Length; -// } -// } - -// public override int Read(byte[] buffer, int offset, int count) -// { -// ValidateBufferArguments(buffer, offset, count); -// return ReadCore(new Span(buffer, offset, count)); -// } - -// public override int Read(Span buffer) -// { -// return ReadCore(buffer); -// } - -// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) -// { -// ValidateBufferArguments(buffer, offset, count); -// return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); -// } - -// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (_encrypting) -// throw new NotSupportedException("Stream is in encryption mode."); - -// if (!_headerRead) -// ReadHeader(); - -// int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); -// if (n > 0) -// { -// // Process the data - work with the Memory span -// byte[] temp = buffer.Slice(0, n).ToArray(); -// ProcessBlock(temp, 0, n); -// temp.CopyTo(buffer.Span); -// _position += n; -// } - -// return n; -// } - -// protected override void Dispose(bool disposing) -// { -// if (_disposed) -// return; - -// if (disposing) -// { -// try -// { -// // For encryption, write the auth code when closing -// if (_encrypting && _headerWritten && !_authCodeValidated) -// { -// WriteAuthCode(); -// } - -// _baseStream.Flush(); -// } -// finally -// { -// _aesEncryptor?.Dispose(); -// _aes.Dispose(); -// _hmac.Dispose(); -// } -// } - -// _disposed = true; -// base.Dispose(disposing); -// } - -// public override bool CanRead => !_encrypting && !_disposed; -// public override bool CanSeek => false; -// public override bool CanWrite => _encrypting && !_disposed; -// public override long Length => throw new NotSupportedException(); -// public override long Position -// { -// get => _position; -// set => throw new NotSupportedException(); -// } - -// public override void Flush() -// { -// ObjectDisposedException.ThrowIf(_disposed, this); -// _baseStream.Flush(); -// } - -// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); -// public override void SetLength(long value) => throw new NotSupportedException(); -// } + internal sealed class WinZipAesStream : Stream + { + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly int _keySizeBits; + private readonly bool _ae2; + private readonly uint? _crc32ForHeader; + private readonly Aes _aes; + private ICryptoTransform? _aesEncryptor; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + private readonly HMACSHA1 _hmac; +#pragma warning restore CA1416 + private readonly byte[] _counterBlock = new byte[16]; + private byte[]? _key; + private byte[]? _hmacKey; + private byte[]? _salt; + private byte[]? _passwordVerifier; + private bool _headerWritten; + private bool _headerRead; + private long _position; + private readonly ReadOnlyMemory _password; + private bool _disposed; + private bool _authCodeValidated; + private readonly long _totalStreamSize; + private long _bytesReadFromBase; + + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1) + { + ArgumentNullException.ThrowIfNull(baseStream); + + + _baseStream = baseStream; + _password = password; + _encrypting = encrypting; + _keySizeBits = keySizeBits; + _ae2 = ae2; + _crc32ForHeader = crc32; + _totalStreamSize = totalStreamSize; // Store the total size + _bytesReadFromBase = 0; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + _aes = Aes.Create(); +#pragma warning restore CA1416 + _aes.Mode = CipherMode.ECB; + _aes.Padding = PaddingMode.None; + +#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? + _hmac = new HMACSHA1(); +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +#pragma warning restore CA1416 + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + if (_encrypting) + { + GenerateKeys(); + InitCipher(); + } + } + + private void DeriveKeysFromPassword() + { + Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(_password.ToArray()); + + try + { + // AES key size + HMAC key size (same as AES key) + password verifier (2 bytes) + int keySizeInBytes = _keySizeBits / 8; + int totalKeySize = keySizeInBytes + keySizeInBytes + 2; + + // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( + passwordBytes, + _salt!, + 1000, + HashAlgorithmName.SHA1, + totalKeySize); + + // Split the derived key material + _key = new byte[keySizeInBytes]; + _hmacKey = new byte[keySizeInBytes]; + _passwordVerifier = new byte[2]; + + // Copy the key material in the correct order + int offset = 0; + + // First: AES encryption key + Buffer.BlockCopy(derivedKey, offset, _key, 0, _key.Length); + offset += _key.Length; + + // Second: HMAC authentication key (same size as encryption key) + Buffer.BlockCopy(derivedKey, offset, _hmacKey, 0, _hmacKey.Length); + offset += _hmacKey.Length; + + // Third: Password verification value (2 bytes) + Buffer.BlockCopy(derivedKey, offset, _passwordVerifier, 0, _passwordVerifier.Length); + + // Clear the derived key from memory + Array.Clear(derivedKey, 0, derivedKey.Length); + } + finally + { + // Clear the password bytes from memory + Array.Clear(passwordBytes, 0, passwordBytes.Length); + } + } + + private void ValidateAuthCode() + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + _baseStream.ReadExactly(storedAuth); + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + + private void GenerateKeys() + { + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(); + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + } + + private void InitCipher() + { + Debug.Assert(_key is not null, "_key is not null"); + + _aes.Key = _key!; + _aesEncryptor = _aes.CreateEncryptor(); + } + + private void WriteHeader() + { + if (_headerWritten) return; + + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + + if (_ae2 && _crc32ForHeader.HasValue) + { + Span crcBytes = stackalloc byte[4]; + BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); + _baseStream.Write(crcBytes); + } + + _headerWritten = true; + } + + private void ReadHeader() + { + if (_headerRead) return; + + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); + + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Debug: Log the verifier + Debug.WriteLine($"Password verifier: {BitConverter.ToString(verifier)}"); + + // Derive keys from password and salt + DeriveKeysFromPassword(); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + // Debug: Log derived verifier + Debug.WriteLine($"Derived verifier: {BitConverter.ToString(_passwordVerifier!)}"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); + } + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + int headerSize = saltSize + 2; // Salt + Password Verifier + _bytesReadFromBase += headerSize; + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } + + private void ProcessBlock(byte[] buffer, int offset, int count) + { + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + + int processed = 0; + byte[] keystream = new byte[16]; + + // Log initial counter state + Debug.WriteLine($"=== ProcessBlock Debug ==="); + Debug.WriteLine($"Processing {count} bytes at offset {offset}"); + Debug.WriteLine($"Initial counter: {BitConverter.ToString(_counterBlock)}"); + + while (processed < count) + { + _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + + // Log keystream for first block + if (processed == 0) + { + Debug.WriteLine($"First keystream block: {BitConverter.ToString(keystream)}"); + } + + IncrementCounter(); + + int blockSize = Math.Min(16, count - processed); + + // For decryption: HMAC is computed on ciphertext BEFORE decryption + if (!_encrypting) + { + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + } + + // XOR the data with the keystream + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + + // For encryption: HMAC is computed on ciphertext AFTER encryption + if (_encrypting) + { + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + } + + processed += blockSize; + } + + Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); + } + + private void IncrementCounter() + { + // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer + for (int i = 0; i < 16; i++) + { + if (++_counterBlock[i] != 0) + break; + } + } + + private void WriteAuthCode() + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + // WinZip AES spec requires only the first 10 bytes of the HMAC + _baseStream.Write(authCode, 0, 10); + } + + _authCodeValidated = true; + } + + private void WriteCore(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; + } + + private int ReadCore(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int bytesToRead = buffer.Length; + + // If we know the total size, ensure we don't read into the HMAC + if (_totalStreamSize > 0) + { + const int hmacSize = 10; // Correct 10-byte HMAC size + long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + Debug.WriteLine($"=== ReadCore Debug ==="); + Debug.WriteLine($"Total stream size: {_totalStreamSize}"); + Debug.WriteLine($"Bytes read from base: {_bytesReadFromBase}"); + Debug.WriteLine($"Remaining data: {remainingData}"); + Debug.WriteLine($"Buffer length requested: {buffer.Length}"); + + if (remainingData <= 0) + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + return 0; + } + + bytesToRead = (int)Math.Min(bytesToRead, remainingData); + } + + if (bytesToRead == 0) + { + if (!_authCodeValidated && _totalStreamSize > 0) + { + ValidateAuthCode(); + } + return 0; + } + + int n = _baseStream.Read(buffer.Slice(0, bytesToRead)); + + Debug.WriteLine($"Read {n} bytes from base stream"); + + if (n > 0) + { + _bytesReadFromBase += n; + + // Log the ciphertext before decryption + Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); + + // The buffer now contains the ciphertext. + // We need to pass an array to ProcessBlock. + byte[] temp = buffer.Slice(0, n).ToArray(); + + // ProcessBlock will now correctly: + // 1. Update the HMAC with the ciphertext from `temp`. + // 2. Decrypt `temp` in-place. + ProcessBlock(temp, 0, n); + + // Log the plaintext after decryption + Debug.WriteLine($"Plaintext (hex): {BitConverter.ToString(temp)}"); + Debug.WriteLine($"Plaintext (ASCII): {System.Text.Encoding.ASCII.GetString(temp)}"); + + // Copy the decrypted data from `temp` back to the original buffer. + temp.CopyTo(buffer); + + _position += n; + } + else // n == 0, meaning end of stream + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + } + + return n; + } + + // All Write overloads redirect to Write(ReadOnlySpan) + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + return Core(buffer, cancellationToken); + + async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += buffer.Length; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return ReadCore(new Span(buffer, offset, count)); + } + + public override int Read(Span buffer) + { + return ReadCore(buffer); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + // Apply the same boundary logic as ReadCore + int bytesToRead = buffer.Length; + + if (_totalStreamSize > 0) + { + const int hmacSize = 10; + long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + if (remainingData <= 0) + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + return 0; + } + + bytesToRead = (int)Math.Min(bytesToRead, remainingData); + } + + if (bytesToRead == 0) + { + if (!_authCodeValidated && _totalStreamSize > 0) + { + ValidateAuthCode(); + } + return 0; + } + + int n = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + + if (n > 0) + { + _bytesReadFromBase += n; // This was missing - crucial for boundary tracking! + + // Process the data + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer.Span); + _position += n; + } + else if (!_authCodeValidated) + { + ValidateAuthCode(); + } + + return n; + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + try + { + // For encryption, write the auth code when closing + if (_encrypting && _headerWritten && !_authCodeValidated) + { + WriteAuthCode(); + } + + // Only flush if the base stream supports writing + // SubReadStream (used for reading compressed data) doesn't support Flush() + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + } + } + + _disposed = true; + base.Dispose(disposing); + } + + public override bool CanRead => !_encrypting && !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => _encrypting && !_disposed; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Only flush if the base stream supports writing + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } } 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 dc9493e35bdf25..a347500bbbf1bf 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 @@ -49,6 +49,8 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; + private CompressionMethodValues _aesCompressionLevel; + private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -424,7 +426,7 @@ private string DecodeEntryString(byte[] entryStringBytes) internal bool EverOpenedForWrite => _everOpenedForWrite; - internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = EncryptionMethod.None) + internal long GetOffsetOfCompressedData() { if (_storedOffsetOfCompressedData == null) { @@ -444,21 +446,11 @@ internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = Encr else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; - // Adjust for AES salt + password verifier using _encryptionMethod - int saltSize = encryptionMethod switch - { - EncryptionMethod.Aes128 => 8, - EncryptionMethod.Aes192 => 12, - EncryptionMethod.Aes256 => 16, - _ => throw new InvalidDataException("Unknown AES encryption method") - }; - - baseOffset += saltSize + 2; // salt + password verifier } _storedOffsetOfCompressedData = baseOffset; @@ -841,7 +833,7 @@ private bool IsZipCryptoEncrypted() private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _storedCompressionMethod == CompressionMethodValues.Aes; + return _aesCompressionLevel == CompressionMethodValues.Aes; } private Stream GetDataDecompressor(Stream compressedStreamToRead) @@ -856,11 +848,15 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: + uncompressedStream = compressedStreamToRead; + break; default: - // we can assume that only deflate/deflate64/stored are allowed because we assume that - // IsOpenable is checked before this function is called - Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); + // We should not get here with Aes as CompressionMethod anymore + // as it should have been replaced with the actual compression method + Debug.Assert(CompressionMethod != CompressionMethodValues.Aes, + "AES compression method should have been replaced with actual compression method"); + // Fallback to stored if we somehow get here uncompressedStream = compressedStreamToRead; break; } @@ -893,9 +889,24 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read if (password.IsEmpty) throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + int keySizeBits = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + // AES implementation placeholder as indicated in the original code // When AES is implemented, create the appropriate decryption stream here - throw new NotSupportedException("AES encryption is not yet supported."); + streamToDecompress = new WinZipAesStream( + baseStream: compressedStream, + password: password, + encrypting: false, // false for decryption + keySizeBits: keySizeBits, + ae2: _aeVersion == 2, // AE-2 format (standard) + crc32: null, + totalStreamSize: _compressedSize); } return GetDataDecompressor(streamToDecompress); } @@ -980,6 +991,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st { return false; } + if (!IsEncrypted && !ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; @@ -988,11 +1000,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - + _aesCompressionLevel = CompressionMethodValues.Aes; byte? aesStrength; ushort? originalCompressionMethod; - - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod)) + ushort? aeVersion; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion)) { message = SR.LocalFileHeaderCorrupt; return false; @@ -1000,22 +1012,35 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st if (aesStrength.HasValue) { - _ = aesStrength switch + EncryptionMethod detectedEncryption = aesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, 3 => EncryptionMethod.Aes256, _ => throw new InvalidDataException("Unknown AES strength") }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + } + if (aeVersion.HasValue) + { + _aeVersion = aeVersion.Value; } + // CRITICAL: Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly if (originalCompressionMethod.HasValue) { - _storedCompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + // Temporarily set the compression method to the actual method for decompression + // Note: We're modifying _storedCompressionMethod, not the property + CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; } } - // when this property gets called, some duplicated work + + // Pass the detected encryption method to GetOffsetOfCompressedData long offsetOfCompressedData = GetOffsetOfCompressedData(); + if (!IsOpenableFinalVerifications(needToLoadIntoMemory, offsetOfCompressedData, out message)) { return false; 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 e6afca04182ca0..21d2a58da47238 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 @@ -671,11 +671,11 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod) + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion) { aesStrength = null; originalCompressionMethod = null; - + aesVersion = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) @@ -708,7 +708,7 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou { // AES extra field structure: // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - reader.ReadBytes(2); + aesVersion = reader.ReadUInt16(); reader.ReadBytes(2); // Vendor ID aesStrength = reader.ReadByte(); // 1, 2, or 3 originalCompressionMethod = reader.ReadUInt16(); diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index 8e4bbc548e2abe..b5c0d08d79f39f 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -2046,7 +2046,6 @@ - From 122f85a72aa4d5eb132ae0d3a66a535b79f06084 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 20 Nov 2025 10:15:44 +0100 Subject: [PATCH 14/83] address zipcrypto comments --- .../System/IO/Compression/ZipCryptoStream.cs | 213 +++++++++++++++--- 1 file changed, 177 insertions(+), 36 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 990fe702247a61..c2dc0b3653a429 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream @@ -31,7 +35,18 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor + // Private constructor for async factory method + private ZipCryptoStream(Stream baseStream, bool encrypting, bool leaveOpen = false, ushort verifierLow2Bytes = 0, uint? crc32ForHeader = null) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + _encrypting = encrypting; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = verifierLow2Bytes; + _crc32ForHeader = crc32ForHeader; + _position = 0; + } + + // Synchronous decryption constructor (existing) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -41,7 +56,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex _position = 0; } - // Encryption constructor + // Synchronous encryption constructor (existing) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, @@ -57,6 +72,40 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } + // Async factory method for decryption + public static async Task CreateForDecryptionAsync( + Stream baseStream, + ReadOnlyMemory password, + byte expectedCheckByte, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseStream); + + var stream = new ZipCryptoStream(baseStream, encrypting: false); + stream.InitKeysFromBytes(password.Span); + await stream.ValidateHeaderAsync(expectedCheckByte, cancellationToken).ConfigureAwait(false); + return stream; + } + + // Async factory method for encryption + public static Task CreateForEncryptionAsync( + Stream baseStream, + ReadOnlyMemory password, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseStream); + cancellationToken.ThrowIfCancellationRequested(); + + var stream = new ZipCryptoStream(baseStream, encrypting: true, leaveOpen, passwordVerifierLow2Bytes, crc32); + stream.InitKeysFromBytes(password.Span); + + // No async work needed for encryption constructor + return Task.FromResult(stream); + } + private void EnsureHeader() { if (!_encrypting || _headerWritten) return; @@ -64,9 +113,7 @@ private void EnsureHeader() Span hdrPlain = stackalloc byte[12]; // bytes 0..9 are random - // TODO: change to actual random data later - for (int i = 0; i < 10; i++) - hdrPlain[i] = 0; + RandomNumberGenerator.Fill(hdrPlain.Slice(0, 10)); // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) if (_crc32ForHeader.HasValue) @@ -96,6 +143,43 @@ private void EnsureHeader() _position += 12; } + private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + { + if (!_encrypting || _headerWritten) return; + + byte[] hdrPlain = new byte[12]; + + // bytes 0..9 are random + RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); + + // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) + if (_crc32ForHeader.HasValue) + { + uint crc = _crc32ForHeader.Value; + hdrPlain[10] = (byte)((crc >> 16) & 0xFF); + hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + } + else + { + hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); + } + + // Encrypt & write; update keys with PLAINTEXT per spec + byte[] hdrCiph = new byte[12]; + for (int i = 0; i < 12; i++) + { + byte ks = DecipherByte(); + byte p = hdrPlain[i]; + hdrCiph[i] = (byte)(p ^ ks); + UpdateKeys(p); + } + + await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); + _headerWritten = true; + _position += 12; + } + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; @@ -126,6 +210,24 @@ private void ValidateHeader(byte expectedCheckByte) throw new InvalidDataException("Invalid password for encrypted ZIP entry."); } + private async ValueTask ValidateHeaderAsync(byte expectedCheckByte, CancellationToken cancellationToken) + { + byte[] hdr = new byte[12]; + int read = 0; + while (read < hdr.Length) + { + int n = await _base.ReadAsync(hdr.AsMemory(read, hdr.Length - read), cancellationToken).ConfigureAwait(false); + if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); + read += n; + } + + for (int i = 0; i < hdr.Length; i++) + hdr[i] = DecryptByte(hdr[i]); + + if (hdr[11] != expectedCheckByte) + throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + } + private void UpdateKeys(byte b) { _key0 = Crc32Update(_key0, b); @@ -163,19 +265,8 @@ public override long Position public override int Read(byte[] buffer, int offset, int count) { - if (!_encrypting) - { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) - throw new ArgumentOutOfRangeException(); - - int n = _base.Read(buffer, offset, count); - for (int i = 0; i < n; i++) - buffer[offset + i] = DecryptByte(buffer[offset + i]); - _position += n; - return n; - } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); } public override int Read(Span destination) @@ -196,24 +287,7 @@ public override int Read(Span destination) public override void Write(byte[] buffer, int offset, int count) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) - throw new ArgumentOutOfRangeException(); - - EnsureHeader(); - - // Simple buffer; optimize with ArrayPool if needed later - byte[] tmp = new byte[count]; - for (int i = 0; i < count; i++) - { - byte ks = DecipherByte(); - byte p = buffer[offset + i]; - tmp[i] = (byte)(p ^ ks); - UpdateKeys(p); - } - _base.Write(tmp, 0, count); - _position += count; + Write(buffer.AsSpan(offset, count)); } public override void Write(ReadOnlySpan buffer) @@ -248,6 +322,73 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + 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) + { + if (!_encrypting) + { + cancellationToken.ThrowIfCancellationRequested(); + + int n = await _base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Span span = buffer.Span; + for (int i = 0; i < n; i++) + span[i] = DecryptByte(span[i]); + _position += n; + return n; + } + throw new NotSupportedException("Stream is in encryption (write-only) mode."); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + + cancellationToken.ThrowIfCancellationRequested(); + + EnsureHeader(); + + byte[] tmp = new byte[buffer.Length]; + ReadOnlySpan span = buffer.Span; + for (int i = 0; i < buffer.Length; i++) + { + byte ks = DecipherByte(); + byte p = span[i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); + } + + await _base.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += tmp.Length; + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _base.FlushAsync(cancellationToken); + } + + public override async ValueTask DisposeAsync() + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + EnsureHeader(); + + if (!_leaveOpen) + await _base.DisposeAsync().ConfigureAwait(false); + + await base.DisposeAsync().ConfigureAwait(false); + } + private static uint Crc32Update(uint crc, byte b) => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } From 55c0613920fcf435cb8ca8fdc7ff803de3927613 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 24 Nov 2025 20:49:09 +0100 Subject: [PATCH 15/83] initial writing test works --- .../tests/ZipFile.Extract.cs | 75 ++++++ .../System/IO/Compression/WinZipAesStream.cs | 225 +++++++++++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 176 +++++++++++++- 3 files changed, 408 insertions(+), 68 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index c8e445f4988816..7b396fe954bee4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1400,6 +1400,81 @@ public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() } } + [Fact] + public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() + { + // Arrange + string tempPath = Path.Join(DownloadsDir, "source_plain_mine.zip"); + const string entryName = "source_plain.txt"; + const string password = "123456789"; + const string expectedContent = "this is plain"; + + // Act 1: Create ZIP with AES-256 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + + } + + + [Fact] + public void CreateBasicArchive() + { + // Arrange + string tempPath = Path.Join(DownloadsDir, "test_simple.zip"); + const string entryName = "test.txt"; + const string expectedContent = "this is plain"; + + // Act 1: Create ZIP with AES-256 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = reader.ReadToEnd(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + + } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 7b6876fb972e14..5d8fe6c0b0a352 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -34,8 +34,11 @@ internal sealed class WinZipAesStream : Stream private bool _authCodeValidated; private readonly long _totalStreamSize; private long _bytesReadFromBase; + private readonly bool _leaveOpen; + private readonly MemoryStream? _encryptionBuffer; - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1) + + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -48,6 +51,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _crc32ForHeader = crc32; _totalStreamSize = totalStreamSize; // Store the total size _bytesReadFromBase = 0; + _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); #pragma warning restore CA1416 @@ -65,6 +69,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encrypting) { + _encryptionBuffer = new MemoryStream(); GenerateKeys(); InitCipher(); } @@ -84,11 +89,11 @@ private void DeriveKeysFromPassword() // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( - passwordBytes, - _salt!, - 1000, - HashAlgorithmName.SHA1, - totalKeySize); + passwordBytes, + _salt!, + 1000, + HashAlgorithmName.SHA1, + totalKeySize); // Split the derived key material _key = new byte[keySizeInBytes]; @@ -171,13 +176,9 @@ private void WriteHeader() _baseStream.Write(_salt); _baseStream.Write(_passwordVerifier); - - if (_ae2 && _crc32ForHeader.HasValue) - { - Span crcBytes = stackalloc byte[4]; - BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); - _baseStream.Write(crcBytes); - } + // output to debug log + Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); + Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); _headerWritten = true; } @@ -219,9 +220,6 @@ private void ReadHeader() _hmac.Key = _hmacKey!; InitCipher(); - int headerSize = saltSize + 2; // Salt + Password Verifier - _bytesReadFromBase += headerSize; - Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; @@ -254,22 +252,27 @@ private void ProcessBlock(byte[] buffer, int offset, int count) int blockSize = Math.Min(16, count - processed); - // For decryption: HMAC is computed on ciphertext BEFORE decryption - if (!_encrypting) + // For encryption: XOR first, then HMAC the ciphertext + if (_encrypting) { + // XOR the data with the keystream to create ciphertext + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + // HMAC is computed on the ciphertext (after XOR) _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); } - - // XOR the data with the keystream - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } - - // For encryption: HMAC is computed on ciphertext AFTER encryption - if (_encrypting) + // For decryption: HMAC first (on ciphertext), then XOR + else { + // HMAC is computed on the ciphertext (before XOR) _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + // XOR the ciphertext with the keystream to recover plaintext + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } } processed += blockSize; @@ -278,6 +281,7 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); } + private void IncrementCounter() { // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer @@ -300,6 +304,7 @@ private void WriteAuthCode() { // WinZip AES spec requires only the first 10 bytes of the HMAC _baseStream.Write(authCode, 0, 10); + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); } _authCodeValidated = true; @@ -315,10 +320,25 @@ private void WriteCore(ReadOnlySpan buffer) WriteHeader(); // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); + //byte[] tmp = buffer.ToArray(); + //ProcessBlock(tmp, 0, tmp.Length); + //_baseStream.Write(tmp); + _encryptionBuffer?.Write(buffer); _position += buffer.Length; + //output tmp to debug log + Debug.WriteLine($"Wrote {buffer.Length} bytes of ciphertext: {BitConverter.ToString(buffer.ToArray())}"); + } + + // Flush all buffered data, encrypt it, and write to base stream + private void FlushEncryptionBuffer() + { + if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) + { + byte[] data = _encryptionBuffer.ToArray(); + ProcessBlock(data, 0, data.Length); + _baseStream.Write(data); + _encryptionBuffer.SetLength(0); // Clear the buffer + } } private int ReadCore(Span buffer) @@ -336,14 +356,14 @@ private int ReadCore(Span buffer) // If we know the total size, ensure we don't read into the HMAC if (_totalStreamSize > 0) { - const int hmacSize = 10; // Correct 10-byte HMAC size - long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + // Calculate the size of the AES overhead + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + const int hmacSize = 10; // 10-byte HMAC - Debug.WriteLine($"=== ReadCore Debug ==="); - Debug.WriteLine($"Total stream size: {_totalStreamSize}"); - Debug.WriteLine($"Bytes read from base: {_bytesReadFromBase}"); - Debug.WriteLine($"Remaining data: {remainingData}"); - Debug.WriteLine($"Buffer length requested: {buffer.Length}"); + // The actual encrypted data size is the total minus header and HMAC + long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + long remainingData = encryptedDataSize - _bytesReadFromBase; if (remainingData <= 0) { @@ -378,18 +398,12 @@ private int ReadCore(Span buffer) Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); // The buffer now contains the ciphertext. - // We need to pass an array to ProcessBlock. byte[] temp = buffer.Slice(0, n).ToArray(); - // ProcessBlock will now correctly: // 1. Update the HMAC with the ciphertext from `temp`. // 2. Decrypt `temp` in-place. ProcessBlock(temp, 0, n); - // Log the plaintext after decryption - Debug.WriteLine($"Plaintext (hex): {BitConverter.ToString(temp)}"); - Debug.WriteLine($"Plaintext (ASCII): {System.Text.Encoding.ASCII.GetString(temp)}"); - // Copy the decrypted data from `temp` back to the original buffer. temp.CopyTo(buffer); @@ -406,7 +420,6 @@ private int ReadCore(Span buffer) return n; } - // All Write overloads redirect to Write(ReadOnlySpan) public override void Write(byte[] buffer, int offset, int count) { ValidateBufferArguments(buffer, offset, count); @@ -477,8 +490,14 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (_totalStreamSize > 0) { + // Calculate the size of the AES overhead + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier const int hmacSize = 10; - long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + // The actual encrypted data size is the total minus header and HMAC + long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + long remainingData = encryptedDataSize - _bytesReadFromBase; if (remainingData <= 0) { @@ -505,7 +524,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (n > 0) { - _bytesReadFromBase += n; // This was missing - crucial for boundary tracking! + _bytesReadFromBase += n; // Process the data byte[] temp = buffer.Slice(0, n).ToArray(); @@ -530,17 +549,22 @@ protected override void Dispose(bool disposing) { try { - // For encryption, write the auth code when closing - if (_encrypting && _headerWritten && !_authCodeValidated) + if (_encrypting && !_authCodeValidated) { + // CRITICAL: Flush the base stream BEFORE calculating auth code + // This ensures all encrypted data is written to the stream + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + WriteAuthCode(); - } - // Only flush if the base stream supports writing - // SubReadStream (used for reading compressed data) doesn't support Flush() - if (_baseStream.CanWrite) - { - _baseStream.Flush(); + // Flush again after writing auth code + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } } } finally @@ -548,6 +572,12 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + + // Only dispose the base stream if we don't leave it open + if (!_leaveOpen) + { + _baseStream.Dispose(); + } } } @@ -555,13 +585,66 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + try + { + if (_encrypting && !_authCodeValidated) + { + FlushEncryptionBuffer(); + } + + _encryptionBuffer?.Dispose(); + + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + + // Only dispose the base stream if we don't leave it open + if (!_leaveOpen) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + } + } + + _disposed = true; + GC.SuppressFinalize(this); + } + public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; public override long Length => throw new NotSupportedException(); public override long Position { - get => _position; + get + { + // Calculate the actual position including all metadata + long position = _position; + + // Add header size if it has been written/read + if (_headerWritten || _headerRead) + { + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + position += headerSize; + } + + // Add auth code size if it has been written/validated + if (_authCodeValidated) + { + const int authCodeSize = 10; + position += authCodeSize; + } + + return position; + } set => throw new NotSupportedException(); } @@ -576,6 +659,38 @@ public override void Flush() } } + public override void Close() + { + if (!_disposed) + { + if (_encrypting && !_authCodeValidated && _headerWritten) + { + // Encrypt all buffered data first + FlushEncryptionBuffer(); + + // Flush any pending data + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + + // Write the authentication code + WriteAuthCode(); + + // Flush again to ensure auth code is written + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + } + + // Call base.Close() which will call Dispose(true) + base.Close(); + } + + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } 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 a347500bbbf1bf..3b9dd1da3c3ddc 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 @@ -609,14 +609,37 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 } + WinZipAesExtraField? aesExtraField = null; + int aesExtraFieldSize = 0; + + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraFieldSize = WinZipAesExtraField.TotalSize; + } + // determine if we can fit zip64 extra field and original extra fields all in int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + + aesExtraFieldSize // Add this line + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); // Modified line _cdUnknownExtraFields = null; } else @@ -668,7 +691,8 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.FilenameLength..], (ushort)_storedEntryNameBytes.Length); @@ -694,6 +718,26 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // only write zip64ExtraField if we decided we need it (it's not null) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); + // Write AES extra field if using AES encryption (add this block) + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraField.WriteBlock(_archive.ArchiveStream); + } + // write extra fields (and any malformed trailing data). ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); @@ -794,7 +838,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool default: compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); break; - } bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; var checkSumStream = new CheckSumAndSizeWriteStream( @@ -836,6 +879,13 @@ private bool IsAesEncrypted() return _aesCompressionLevel == CompressionMethodValues.Aes; } + private bool ForAesEncryption() + { + return _encryptionMethod == EncryptionMethod.Aes128 || + _encryptionMethod == EncryptionMethod.Aes192 || + _encryptionMethod == EncryptionMethod.Aes256; + } + private Stream GetDataDecompressor(Stream compressedStreamToRead) { Stream? uncompressedStream; @@ -942,9 +992,26 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } + else if (encryptionMethod == EncryptionMethod.Aes256) + { + if (string.IsNullOrEmpty(password)) + throw new InvalidOperationException("Password is required for encryption."); + + Encryption = encryptionMethod; + + // targetstream should be new winzipaesstream for wrting, ae2 + targetStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + password: password.AsMemory(), + encrypting: true, + keySizeBits: 256, + ae2: true, + leaveOpen: true); + } + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - true, + encryptionMethod == EncryptionMethod.Aes256 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1166,6 +1233,36 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } + internal sealed class WinZipAesExtraField + { + public const ushort HeaderId = 0x9901; + + public ushort VendorVersion { get; set; } = 2; // AE-2 + public byte AesStrength { get; set; } // 1=128bit, 2=192bit, 3=256bit + public ushort CompressionMethod { get; set; } // Original compression method + + public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + + public void WriteBlock(Stream stream) + { + Span buffer = stackalloc byte[TotalSize]; + WriteBlockCore(buffer); + stream.Write(buffer); + } + + private void WriteBlockCore(Span buffer) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..], HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..], 7); // Data size + BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..], VendorVersion); + // Write "AE" as two ASCII bytes, vendor ID + buffer[6] = (byte)'A'; // 0x41 + buffer[7] = (byte)'E'; // 0x45 + buffer[8] = AesStrength; + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); + } + } + private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and @@ -1178,6 +1275,10 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // save offset _offsetOfLocalHeader = _archive.ArchiveStream.Position; + // for extra winzip aes header + WinZipAesExtraField? aesExtraField = null; + int aesExtraFieldSize = 0; + // if we already know that we have an empty file don't worry about anything, just do a straight shot of the header if (isEmptyFile) { @@ -1190,7 +1291,6 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { - if (Encryption == EncryptionMethod.ZipCrypto) { // Streaming mode for encryption: sizes and CRC unknown upfront @@ -1200,6 +1300,31 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; Debug.Assert(_crc32 == 0); } + else if (ForAesEncryption()) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + + // Set compression method to 99 (AES indicator) in the header + CompressionMethod = CompressionMethodValues.Aes; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraFieldSize = 11; + } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header else if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) @@ -1216,7 +1341,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor; if (ShouldUseZIP64 #if DEBUG_FORCE_ZIP64 - || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) + || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) #endif ) { @@ -1246,11 +1371,12 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // calculate extra field. if zip64 stuff + original extraField aren't going to fit, dump the original extraField, because this is more important int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + + aesExtraFieldSize + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _lhUnknownExtraFields = null; } else @@ -1284,7 +1410,8 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.FilenameLength..], (ushort)_storedEntryNameBytes.Length); @@ -1303,9 +1430,30 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) _archive.ArchiveStream.Write(lfStaticHeader); _archive.ArchiveStream.Write(_storedEntryNameBytes); - // Only when handling zip64 + // Write Zip64 extra field if needed zip64ExtraField?.WriteBlock(_archive.ArchiveStream); + // Write AES extra field if using AES encryption + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraField.WriteBlock(_archive.ArchiveStream); + } + + // Write other extra fields ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); } @@ -1465,8 +1613,8 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCrc32Location = ZipLocalFileHeader.FieldLocations.Crc32 - ZipLocalFileHeader.FieldLocations.Crc32; int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; - - BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); } @@ -1494,7 +1642,8 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) int bytesToWrite; ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); - BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) { From cc6d30d09450c1db41a3d4a7b9c823b4689664b9 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 26 Nov 2025 20:51:59 +0100 Subject: [PATCH 16/83] add more tests for winzipaesstream and work on async methods --- .../tests/ZipFile.Extract.cs | 838 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 150 ++-- .../IO/Compression/ZipArchiveEntry.Async.cs | 47 +- .../System/IO/Compression/ZipArchiveEntry.cs | 37 +- 4 files changed, 995 insertions(+), 77 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 7b396fe954bee4..253fca3c664901 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -265,7 +265,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; - + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); if (File.Exists(tempFile)) File.Delete(tempFile); @@ -1437,23 +1437,197 @@ public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() } + [Fact] + public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes256_entries.zip"); + const string password = "123456789"; + + var entries = new (string Name, string Content)[] + { + ("entry1.txt", "First encrypted entry"), + ("folder/entry2.txt", "Second encrypted entry in folder"), + ("folder/subfolder/entry3.md", "# Third Entry\nMarkdown content"), + ("data.json", "{\"key\": \"value\", \"encrypted\": true}"), + ("readme.txt", "This is AES-256 encrypted content") + }; + + // Act 1: Create ZIP with multiple AES-256 encrypted entries + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back all encrypted entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + // Assert each entry's content matches + Assert.Equal(expectedContent, actualContent); + } + + // Verify wrong password fails + var firstEntry = archive.GetEntry(entries[0].Name); + Assert.NotNull(firstEntry); + } + } [Fact] - public void CreateBasicArchive() + public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() { // Arrange - string tempPath = Path.Join(DownloadsDir, "test_simple.zip"); - const string entryName = "test.txt"; - const string expectedContent = "this is plain"; + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes256_diff_passwords.zip"); - // Act 1: Create ZIP with AES-256 encrypted entry + var entries = new (string Name, string Content, string Password)[] + { + ("secure1.txt", "Content with password1", "password1"), + ("secure2.txt", "Content with password2", "password2"), + ("folder/secure3.txt", "Content with password3", "password3") + }; + + // Act 1: Create ZIP with AES-256 encrypted entries using different passwords + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back entries with their respective passwords + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + // Correct password should work + using (var entryStream = entry!.Open(pwd)) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + } + + [Fact] + public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "mixed_plain_aes256.zip"); + const string password = "securePassword123"; + + var encryptedEntries = new (string Name, string Content)[] + { + ("secure/credentials.txt", "username=admin\npassword=secret"), + ("secure/data.json", "{\"sensitive\": true}") + }; + + var plainEntries = new (string Name, string Content)[] + { + ("readme.txt", "This archive contains both encrypted and plain files"), + ("public/info.txt", "This is public information") + }; + + // Act 1: Create ZIP with both plain and AES-256 encrypted entries + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + // Add encrypted entries + foreach (var (name, content) in encryptedEntries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + + // Add plain entries + foreach (var (name, content) in plainEntries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back both encrypted and plain entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + // Read encrypted entries with password + foreach (var (name, expectedContent) in encryptedEntries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + + // Read plain entries without password + foreach (var (name, expectedContent) in plainEntries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + + [Fact] + public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_single.zip"); + const string entryName = "test_aes128.txt"; + const string password = "Test123!@#"; + const string expectedContent = "This content is encrypted with AES-128"; + + // Act 1: Create ZIP with AES-128 encrypted entry using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(expectedContent); + await writer.WriteAsync(expectedContent); } // Act 2: Read back the encrypted entry @@ -1464,16 +1638,660 @@ public void CreateBasicArchive() var entry = archive.GetEntry(entryName); Assert.NotNull(entry); - using var entryStream = entry!.Open(); + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + + } + + [Fact] + public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_single.zip"); + const string entryName = "test_aes192.txt"; + const string password = "SecurePass456$%^"; + const string expectedContent = "This content is protected with AES-192 encryption"; + + // Act 1: Create ZIP with AES-192 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "mixed_aes_levels.zip"); + + var entries = new (string Name, string Content, string Password, ZipArchiveEntry.EncryptionMethod Encryption)[] + { + ("aes128/file1.txt", "AES-128 encrypted content", "password128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes192/file2.txt", "AES-192 encrypted content", "password192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("aes256/file3.txt", "AES-256 encrypted content", "password256", ZipArchiveEntry.EncryptionMethod.Aes256), + ("mixed/doc1.json", "{\"encryption\": \"AES-128\"}", "jsonPass128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("mixed/doc2.xml", "AES-192", "xmlPass192", ZipArchiveEntry.EncryptionMethod.Aes192) + }; + + // Act 1: Create ZIP with entries using different AES encryption levels + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, encryption); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + // Act 2: Read back all encrypted entries with their respective passwords + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + // Correct password should work + using (var entryStream = entry!.Open(pwd)) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = reader.ReadToEnd(); + Assert.Equal(expectedContent, actualContent); + } + + } + } + } + + [Fact] + public void CreateLargeFileWithAES128_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_large2.zip"); + const string entryName = "large_file.bin"; + const string password = "LargeFilePass123!"; + + // Create a larger content + var random = new Random(42); // Seed for reproducibility + var largeContent = new byte[1024 * 1024]; + random.NextBytes(largeContent); + + // Act 1: Create ZIP with AES-128 encrypted large entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + entryStream.Write(largeContent); + } + + // Act 2: Read back and verify the large encrypted entry + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var ms = new MemoryStream(); + entryStream.CopyTo(ms); + var actualContent = ms.ToArray(); + + // Assert + Assert.Equal(largeContent.Length, actualContent.Length); + Assert.Equal(largeContent, actualContent); + } + } + + [Fact] + public async Task CreateCompressedAndAES192Encrypted_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_compressed.zip"); + const string password = "CompressedPass!"; + + // Create highly compressible content + string repeatedContent = string.Join("\n", Enumerable.Repeat("This line is repeated many times to test compression with AES-192.", 100)); + + var entries = new (string Name, string Content, CompressionLevel Level)[] + { + ("optimal.txt", repeatedContent, CompressionLevel.Optimal), + ("fastest.txt", repeatedContent, CompressionLevel.Fastest), + ("smallest.txt", repeatedContent, CompressionLevel.SmallestSize), + ("nocompression.txt", repeatedContent, CompressionLevel.NoCompression) + }; + + // Act 1: Create ZIP with AES-192 encrypted entries at different compression levels + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, level) in entries) + { + var entry = archive.CreateEntry(name, level); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + // Act 2: Read back all entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + Assert.Equal(expectedContent, actualContent); + } + } + + // Verify file sizes are different due to compression levels + var fileInfo = new FileInfo(tempPath); + Assert.True(fileInfo.Exists); + Assert.True(fileInfo.Length > 0); + } + + [Fact] + public async Task MixAllEncryptionTypes_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "all_encryption_types.zip"); + + var entries = new (string Name, string Content, string? Password, ZipArchiveEntry.EncryptionMethod? Encryption)[] + { + // Plain entry + ("plain/readme.txt", "This is a plain unencrypted file", null, null), + + // ZipCrypto + ("zipcrypto/secret.txt", "ZipCrypto encrypted content", "zipPass", ZipArchiveEntry.EncryptionMethod.ZipCrypto), + + // AES-128 + ("aes128/data.txt", "AES-128 encrypted data", "aes128Pass", ZipArchiveEntry.EncryptionMethod.Aes128), + + // AES-192 + ("aes192/config.json", "{\"level\": \"AES-192\"}", "aes192Pass", ZipArchiveEntry.EncryptionMethod.Aes192), + + // AES-256 + ("aes256/secure.xml", "AES-256 secured", "aes256Pass", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Act 1: Create ZIP with all encryption types + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + Stream entryStream = pwd != null && encryption.HasValue + ? entry.Open(pwd, encryption.Value) + : entry.Open(); + + using (entryStream) + using (var writer = new StreamWriter(entryStream, Encoding.UTF8)) + { + await writer.WriteAsync(content); + } + } + } + + // Act 2: Read back all entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + Stream entryStream = pwd != null + ? entry!.Open(pwd) + : entry!.Open(); + + using (entryStream) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + } + + [Fact] + public async Task AES128WithSpecialCharacters_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_special_chars.zip"); + const string password = "パスワード123!@#"; // Japanese characters in password + + var entries = new (string Name, string Content)[] + { + ("unicode/chinese.txt", "你好世界 - Hello World in Chinese"), + ("unicode/arabic.txt", "مرحبا بالعالم - Hello World in Arabic"), + ("unicode/emoji.txt", "Hello 👋 World 🌍 with emojis! 🎉"), + ("unicode/mixed.txt", "Ñiño José façade naïve Zürich") + }; + + // Act 1: Create ZIP with AES-128 encrypted Unicode content + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back and verify Unicode content + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + Assert.Equal(expectedContent, actualContent); + } + } + } + + [Fact] + public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes256_async_operations.zip"); + const string entryName = "async_test.txt"; + const string password = "AsyncPass123!"; + const string expectedContent = "This content was written and read asynchronously with AES-256"; + + // Act 1: Create ZIP with AES-256 encrypted entry using async write + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + + // Use async write operations + byte[] contentBytes = Encoding.UTF8.GetBytes(expectedContent); + await entryStream.WriteAsync(contentBytes, 0, contentBytes.Length); + await entryStream.FlushAsync(); + } + + // Act 2: Read back using async operations + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = reader.ReadToEnd(); + actualContent = await reader.ReadToEndAsync(); } // Assert Assert.Equal(expectedContent, actualContent); } + [Fact] + public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes_async_writes.zip"); + + var entries = new (string Name, byte[] Content, string Password, ZipArchiveEntry.EncryptionMethod Encryption)[] + { + ("async128.bin", Encoding.UTF8.GetBytes("AES-128 async content"), "pass128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("async192.bin", Encoding.UTF8.GetBytes("AES-192 async content"), "pass192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("async256.bin", Encoding.UTF8.GetBytes("AES-256 async content"), "pass256", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Act 1: Create entries with async writes + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, encryption); + + // Write asynchronously + await entryStream.WriteAsync(content, 0, content.Length); + await entryStream.FlushAsync(); + } + } + + // Act 2: Read back with async operations + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(pwd); + + // Read asynchronously + var buffer = new byte[expectedContent.Length]; + int totalRead = 0; + while (totalRead < buffer.Length) + { + int bytesRead = await entryStream.ReadAsync(buffer, totalRead, buffer.Length - totalRead); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + + Assert.Equal(expectedContent, buffer); + } + } + } + + [Fact] + public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_large_async.zip"); + const string entryName = "large_async.bin"; + const string password = "LargeAsync!@#"; + + // Create larger test data + var random = new Random(123); + var largeData = new byte[256 * 1024]; // 256KB + random.NextBytes(largeData); + + // Act 1: Write large data asynchronously with AES-128 + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + + // Write in chunks asynchronously + const int chunkSize = 8192; + for (int offset = 0; offset < largeData.Length; offset += chunkSize) + { + int writeSize = Math.Min(chunkSize, largeData.Length - offset); + await entryStream.WriteAsync(largeData, offset, writeSize); + } + await entryStream.FlushAsync(); + } + + // Act 2: Read back asynchronously + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var ms = new MemoryStream(); + + // Read in chunks asynchronously + await entryStream.CopyToAsync(ms, bufferSize: 8192); + var actualData = ms.ToArray(); + // Assert + Assert.Equal(largeData.Length, actualData.Length); + Assert.Equal(largeData, actualData); + } + } + + [Fact] + public async Task StreamCopyAsyncWithAES192_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_stream_copy.zip"); + const string entryName = "stream_copy.dat"; + const string password = "StreamCopy192!"; + + // Create test data + var testData = new byte[64 * 1024]; // 64KB + new Random(456).NextBytes(testData); + + // Act 1: Use CopyToAsync to write + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var sourceStream = new MemoryStream(testData); + + await sourceStream.CopyToAsync(entryStream); + } + + // Act 2: Use CopyToAsync to read + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var destStream = new MemoryStream(); + + await entryStream.CopyToAsync(destStream); + var actualData = destStream.ToArray(); + + // Assert + Assert.Equal(testData, actualData); + } + } + + [Fact] + public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes256_multiple_writes.zip"); + const string entryName = "multi_write.txt"; + const string password = "MultiWrite256"; + + var parts = new[] + { + "First part of content\n", + "Second part of content\n", + "Third part of content\n", + "Final part of content" + }; + string expectedContent = string.Join("", parts); + + // Act 1: Write multiple times to same entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + + // Write each part asynchronously + foreach (var part in parts) + { + byte[] partBytes = Encoding.UTF8.GetBytes(part); + await entryStream.WriteAsync(partBytes, 0, partBytes.Length); + } + await entryStream.FlushAsync(); + } + + // Act 2: Read back all content + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream); + string actualContent = await reader.ReadToEndAsync(); + + // Assert + Assert.Equal(expectedContent, actualContent); + } + } + + [Fact] + public async Task AsyncReadInChunks_AES128_VerifyContent() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_chunked_read.zip"); + const string entryName = "chunked.bin"; + const string password = "ChunkedRead128"; + + // Create recognizable pattern + var pattern = new byte[1024]; + for (int i = 0; i < pattern.Length; i++) + { + pattern[i] = (byte)(i % 256); + } + + // Act 1: Write pattern + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + await entryStream.WriteAsync(pattern, 0, pattern.Length); + } + + // Act 2: Read in small chunks and verify + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + + // Read in 100-byte chunks + const int chunkSize = 16; + var readBuffer = new byte[chunkSize]; + var allData = new List(); + + int bytesRead; + while ((bytesRead = await entryStream.ReadAsync(readBuffer, 0, chunkSize)) > 0) + { + allData.AddRange(readBuffer.Take(bytesRead)); + } + + // Assert + Assert.Equal(pattern, allData.ToArray()); + } + } + + [Fact] + public async Task MixedSyncAsyncOperations_AES192_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_mixed_ops.zip"); + + var entries = new[] + { + ("sync_write.txt", "Written synchronously", "syncPass"), + ("async_write.txt", "Written asynchronously", "asyncPass") + }; + + // Act 1: Mix sync and async writes + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + // Synchronous write + var syncEntry = archive.CreateEntry(entries[0].Item1); + using (var syncStream = syncEntry.Open(entries[0].Item3, ZipArchiveEntry.EncryptionMethod.Aes192)) + { + byte[] syncBytes = Encoding.UTF8.GetBytes(entries[0].Item2); + syncStream.Write(syncBytes, 0, syncBytes.Length); + } + + // Asynchronous write + var asyncEntry = archive.CreateEntry(entries[1].Item1); + using (var asyncStream = asyncEntry.Open(entries[1].Item3, ZipArchiveEntry.EncryptionMethod.Aes192)) + { + byte[] asyncBytes = Encoding.UTF8.GetBytes(entries[1].Item2); + await asyncStream.WriteAsync(asyncBytes, 0, asyncBytes.Length); + } + } + + // Act 2: Read back with mixed operations + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + // Read first entry asynchronously + var entry1 = archive.GetEntry(entries[0].Item1); + Assert.NotNull(entry1); + using (var stream1 = entry1!.Open(entries[0].Item3)) + using (var reader1 = new StreamReader(stream1)) + { + string content1 = await reader1.ReadToEndAsync(); + Assert.Equal(entries[0].Item2, content1); + } + + // Read second entry synchronously + var entry2 = archive.GetEntry(entries[1].Item1); + Assert.NotNull(entry2); + using (var stream2 = entry2!.Open(entries[1].Item3)) + using (var reader2 = new StreamReader(stream2)) + { + string content2 = reader2.ReadToEnd(); + Assert.Equal(entries[1].Item2, content2); + } + } + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 5d8fe6c0b0a352..28ab01fdb7de7b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -183,6 +183,18 @@ private void WriteHeader() _headerWritten = true; } + private async Task WriteHeaderAsync(CancellationToken cancellationToken) + { + if (_headerWritten) return; + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); + // output to debug log + Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); + Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); + _headerWritten = true; + } + private void ReadHeader() { if (_headerRead) return; @@ -310,6 +322,24 @@ private void WriteAuthCode() _authCodeValidated = true; } + private async Task WriteAuthCodeAsync(CancellationToken cancellationToken) + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + // WinZip AES spec requires only the first 10 bytes of the HMAC + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); + } + + _authCodeValidated = true; + } + private void WriteCore(ReadOnlySpan buffer) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -319,16 +349,13 @@ private void WriteCore(ReadOnlySpan buffer) WriteHeader(); - // We need to copy the data since ProcessBlock modifies it in place - //byte[] tmp = buffer.ToArray(); - //ProcessBlock(tmp, 0, tmp.Length); - //_baseStream.Write(tmp); + // Buffer the data - don't process it yet _encryptionBuffer?.Write(buffer); - _position += buffer.Length; - //output tmp to debug log - Debug.WriteLine($"Wrote {buffer.Length} bytes of ciphertext: {BitConverter.ToString(buffer.ToArray())}"); + + Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); } + // Flush all buffered data, encrypt it, and write to base stream private void FlushEncryptionBuffer() { @@ -337,6 +364,20 @@ private void FlushEncryptionBuffer() byte[] data = _encryptionBuffer.ToArray(); ProcessBlock(data, 0, data.Length); _baseStream.Write(data); + _position += data.Length; + _encryptionBuffer.SetLength(0); // Clear the buffer + } + } + + // Replace the FlushEncryptionBufferAsync method with this version: + private async Task FlushEncryptionBufferAsync(CancellationToken cancellationToken) + { + if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) + { + byte[] data = _encryptionBuffer.ToArray(); + ProcessBlock(data, 0, data.Length); + await _baseStream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + _position += data.Length; _encryptionBuffer.SetLength(0); // Clear the buffer } } @@ -437,25 +478,21 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); } - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + // Replace the WriteAsync method with this: + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - return Core(buffer, cancellationToken); + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); - async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - WriteHeader(); + // Just buffer the data + // The async version should match the sync version logic + await _encryptionBuffer!.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += buffer.Length; - } + Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); } public override int Read(byte[] buffer, int offset, int count) @@ -549,18 +586,21 @@ protected override void Dispose(bool disposing) { try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeValidated && _headerWritten) { - // CRITICAL: Flush the base stream BEFORE calculating auth code - // This ensures all encrypted data is written to the stream + // Encrypt all buffered data first + FlushEncryptionBuffer(); + + // Flush any pending data if (_baseStream.CanWrite) { _baseStream.Flush(); } + // Write the authentication code WriteAuthCode(); - // Flush again after writing auth code + // Flush again to ensure auth code is written if (_baseStream.CanWrite) { _baseStream.Flush(); @@ -572,6 +612,7 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + _encryptionBuffer?.Dispose(); // Only dispose the base stream if we don't leave it open if (!_leaveOpen) @@ -585,6 +626,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + // Replace DisposeAsync with this version: public override async ValueTask DisposeAsync() { if (_disposed) @@ -592,19 +634,36 @@ public override async ValueTask DisposeAsync() try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeValidated && _headerWritten) { - FlushEncryptionBuffer(); - } + // make sure header is flushed + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - _encryptionBuffer?.Dispose(); + // Encrypt all buffered data first + await FlushEncryptionBufferAsync(CancellationToken.None).ConfigureAwait(false); + + // Flush any pending data + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + } + // Write the authentication code (this is still sync, which is fine) + await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + + // Flush again to ensure auth code is written + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + } + } } finally { _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + _encryptionBuffer?.Dispose(); // Only dispose the base stream if we don't leave it open if (!_leaveOpen) @@ -616,7 +675,6 @@ public override async ValueTask DisposeAsync() _disposed = true; GC.SuppressFinalize(this); } - public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; @@ -658,39 +716,27 @@ public override void Flush() _baseStream.Flush(); } } - - public override void Close() + public override async Task FlushAsync(CancellationToken cancellationToken) { - if (!_disposed) + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) { - if (_encrypting && !_authCodeValidated && _headerWritten) + // First flush base stream to ensure header is written + if (_baseStream.CanWrite) { - // Encrypt all buffered data first - FlushEncryptionBuffer(); - - // Flush any pending data - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } - - // Write the authentication code - WriteAuthCode(); - - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } + } - // Call base.Close() which will call Dispose(true) - base.Close(); + // Finally flush base stream to ensure encrypted data is written + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 48b946bd1f9669..75d5f099c13863 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -146,12 +146,33 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel await _archive.ArchiveStream.WriteAsync(cdStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); - // only write zip64ExtraField if we decided we need it (it's not null) + // Write zip64ExtraField first if we decided we need it if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } + // Write WinZip AES extra field AFTER Zip64 (matching sync version order) + // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader + if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = _encryptionMethod switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } + // write extra fields (and any malformed trailing data). await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -161,7 +182,6 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel } } } - internal async Task LoadLocalHeaderExtraFieldIfNeededAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -289,18 +309,37 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW await _archive.ArchiveStream.WriteAsync(lfStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); - // Only when handling zip64 if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } + // Write WinZip AES extra field if using AES encryption + // Must match the exact check used in the sync version WriteLocalFileHeader + if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = _encryptionMethod switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } + await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } return zip64ExtraField != null; } - private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); 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 3b9dd1da3c3ddc..d0eb44a2215aa7 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 @@ -992,26 +992,35 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } - else if (encryptionMethod == EncryptionMethod.Aes256) + else if (encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("Password is required for encryption."); Encryption = encryptionMethod; + // use switch to calculate keysizebits based on encryption strength + int keysizebits = encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + // targetstream should be new winzipaesstream for wrting, ae2 targetStream = new WinZipAesStream( baseStream: _archive.ArchiveStream, password: password.AsMemory(), encrypting: true, - keySizeBits: 256, + keySizeBits: keysizebits, ae2: true, leaveOpen: true); } CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod == EncryptionMethod.Aes256 ? false : true, + encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1245,24 +1254,30 @@ internal sealed class WinZipAesExtraField public void WriteBlock(Stream stream) { - Span buffer = stackalloc byte[TotalSize]; + Span buffer = new byte[TotalSize]; WriteBlockCore(buffer); stream.Write(buffer); } + public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + private void WriteBlockCore(Span buffer) { - BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..], HeaderId); - BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..], 7); // Data size - BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..], VendorVersion); - // Write "AE" as two ASCII bytes, vendor ID - buffer[6] = (byte)'A'; // 0x41 - buffer[7] = (byte)'E'; // 0x45 + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); + buffer[6] = (byte)'A'; + buffer[7] = (byte)'E'; buffer[8] = AesStrength; + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); } } - private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and From 6732696fe982903e47edb7a49647fab0aa526920 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 27 Nov 2025 19:30:18 +0100 Subject: [PATCH 17/83] async methods done, fix ae-1 validation and add more tests --- .../tests/ZipFile.Extract.cs | 324 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 47 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 36 +- .../src/System/IO/Compression/ZipBlocks.cs | 10 +- .../System/IO/Compression/ZipCryptoStream.cs | 26 +- .../System/IO/Compression/ZipCustomStreams.cs | 210 ++++++++++++ 6 files changed, 611 insertions(+), 42 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 253fca3c664901..1e46b8bf8d08a1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -237,7 +237,6 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() { - string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; string CorrectPassword = "123456789"; @@ -694,7 +693,309 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } + [Fact] + public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_async_test.zip"); + const string entryName = "async_test.txt"; + const string password = "AsyncP@ss123"; + const string expectedContent = "This is async ZipCrypto content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create archive with async write + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + byte[] data = Encoding.UTF8.GetBytes(expectedContent); + await stream.WriteAsync(data, 0, data.Length); + await stream.FlushAsync(); + } + + // Act 2: Read back with async read + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + using var stream = entry!.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_write.zip"); + const string entryName = "multi_write.txt"; + const string password = "MultiWrite123"; + + var parts = new[] { "Part1-", "Part2-", "Part3" }; + string expectedContent = string.Concat(parts); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create with multiple async writes + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + foreach (var part in parts) + { + byte[] data = Encoding.UTF8.GetBytes(part); + await stream.WriteAsync(data, 0, data.Length); + } + await stream.FlushAsync(); + } + + // Act 2: Read back + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_chunked_read.zip"); + const string entryName = "chunked.txt"; + const string password = "ChunkedRead!"; + + // Create larger content + string expectedContent = string.Concat(Enumerable.Repeat("0123456789ABCDEF", 100)); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); + await writer.WriteAsync(expectedContent); + } + + // Act 2: Read in chunks asynchronously using StreamReader to handle BOM properly + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_mixed_ops.zip"); + const string syncEntryName = "sync.txt"; + const string asyncEntryName = "async.txt"; + const string password = "MixedOps123"; + const string syncContent = "Synchronous write content"; + const string asyncContent = "Asynchronous write content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create with mixed sync/async writes + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Synchronous write + var syncEntry = za.CreateEntry(syncEntryName); + using (var syncWriter = new StreamWriter(syncEntry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8)) + { + syncWriter.Write(syncContent); + } + + // Asynchronous write + var asyncEntry = za.CreateEntry(asyncEntryName); + using (var asyncWriter = new StreamWriter(asyncEntry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8)) + { + await asyncWriter.WriteAsync(asyncContent); + } + } + + // Act 2: Read with mixed sync/async reads + string actualSyncContent, actualAsyncContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + // Async read of sync-written entry + var syncEntry = za.GetEntry(syncEntryName); + Assert.NotNull(syncEntry); + using (var reader1 = new StreamReader(syncEntry!.Open(password), Encoding.UTF8)) + { + actualSyncContent = await reader1.ReadToEndAsync(); + } + + // Sync read of async-written entry + var asyncEntry = za.GetEntry(asyncEntryName); + Assert.NotNull(asyncEntry); + using (var reader2 = new StreamReader(asyncEntry!.Open(password), Encoding.UTF8)) + { + actualAsyncContent = reader2.ReadToEnd(); + } + } + + // Assert + Assert.Equal(syncContent, actualSyncContent); + Assert.Equal(asyncContent, actualAsyncContent); + } + + [Fact] + public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_large_async.zip"); + const string entryName = "large.bin"; + const string password = "LargeFile123"; + + // Create 1MB of random data + var random = new Random(42); + var expectedData = new byte[1024 * 1024]; + random.NextBytes(expectedData); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Write large data asynchronously + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + // Write in 64KB chunks + const int chunkSize = 65536; + for (int offset = 0; offset < expectedData.Length; offset += chunkSize) + { + int count = Math.Min(chunkSize, expectedData.Length - offset); + await stream.WriteAsync(expectedData, offset, count); + } + await stream.FlushAsync(); + } + + // Act 2: Read large data asynchronously + byte[] actualData; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(password); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + actualData = ms.ToArray(); + } + + // Assert + Assert.Equal(expectedData.Length, actualData.Length); + Assert.Equal(expectedData, actualData); + } + + [Fact] + public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_copyto.zip"); + const string entryName = "copyto.dat"; + const string password = "CopyTo123!"; + + var expectedData = new byte[32768]; + new Random(123).NextBytes(expectedData); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Write using CopyToAsync + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var sourceStream = new MemoryStream(expectedData); + + await sourceStream.CopyToAsync(entryStream); + } + + // Act 2: Read using CopyToAsync + byte[] actualData; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var destStream = new MemoryStream(); + + await entryStream.CopyToAsync(destStream); + actualData = destStream.ToArray(); + } + + // Assert + Assert.Equal(expectedData, actualData); + } + + [Fact] + public async Task ZipCrypto_AsyncWithWrongPassword_ThrowsInvalidDataException() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_wrong_pw_async.zip"); + const string entryName = "secure.txt"; + const string correctPassword = "Correct123"; + const string wrongPassword = "Wrong123"; + const string content = "Secret content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open(correctPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); + await writer.WriteAsync(content); + } + + // Act & Assert - Try to read with wrong password + await Assert.ThrowsAsync(async () => + { + using var za = ZipFile.Open(zipPath, ZipArchiveMode.Read); + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(wrongPassword); + byte[] buffer = new byte[100]; + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + } [Fact] public async Task Update_AddEncryptedEntry_RoundTrip() @@ -1898,6 +2199,27 @@ public async Task MixAllEncryptionTypes_RoundTrip() } } + [Fact] + public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() + { + // Arrange + string zipPath = Path.Join(DownloadsDir, "source_plain_ae1.zip"); + const string entryName = "source_plain.txt"; + const string password = "123456789"; + const string expectedContent = "this is plain"; + + // Act + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(entryName)); + + using var stream = entry.Open(password); + using var reader = new StreamReader(stream); + string actualContent = reader.ReadToEnd(); + + // Assert + Assert.Equal(expectedContent, actualContent); + } + [Fact] public async Task AES128WithSpecialCharacters_RoundTrip() { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 28ab01fdb7de7b..a7859dd26824cc 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -14,8 +14,6 @@ internal sealed class WinZipAesStream : Stream private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; - private readonly bool _ae2; - private readonly uint? _crc32ForHeader; private readonly Aes _aes; private ICryptoTransform? _aesEncryptor; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms @@ -37,8 +35,7 @@ internal sealed class WinZipAesStream : Stream private readonly bool _leaveOpen; private readonly MemoryStream? _encryptionBuffer; - - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1, bool leaveOpen = false) + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -47,8 +44,6 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _password = password; _encrypting = encrypting; _keySizeBits = keySizeBits; - _ae2 = ae2; - _crc32ForHeader = crc32; _totalStreamSize = totalStreamSize; // Store the total size _bytesReadFromBase = 0; _leaveOpen = leaveOpen; @@ -147,6 +142,29 @@ private void ValidateAuthCode() _authCodeValidated = true; } + private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + private void GenerateKeys() { // 8 for AES-128, 12 for AES-192, 16 for AES-256 @@ -293,7 +311,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); } - private void IncrementCounter() { // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer @@ -540,7 +557,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation { if (!_authCodeValidated) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return 0; } @@ -552,7 +569,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation { if (!_authCodeValidated && _totalStreamSize > 0) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return 0; } @@ -571,7 +588,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation } else if (!_authCodeValidated) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return n; @@ -606,6 +623,11 @@ protected override void Dispose(bool disposing) _baseStream.Flush(); } } + else if (!_encrypting && !_authCodeValidated && _headerRead) + { + // For decryption, validate auth code and CRC if not already done + ValidateAuthCode(); + } } finally { @@ -657,6 +679,11 @@ public override async ValueTask DisposeAsync() await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); } } + else if (!_encrypting && !_authCodeValidated && _headerRead) + { + // For decryption, validate auth code and CRC if not already done + await ValidateAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + } } finally { 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 edeb1d2a5413bb..0b9ddb8799aa86 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 @@ -446,7 +446,7 @@ internal long GetOffsetOfCompressedData() else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; @@ -954,21 +954,27 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, EncryptionMethod.Aes256 => 256, - _ => 256 // Default to AES-256 + _ => 256 // default for aes }; - // AES implementation placeholder as indicated in the original code - // When AES is implemented, create the appropriate decryption stream here streamToDecompress = new WinZipAesStream( baseStream: compressedStream, password: password, - encrypting: false, // false for decryption + encrypting: false, keySizeBits: keySizeBits, - ae2: _aeVersion == 2, // AE-2 format (standard) - crc32: null, totalStreamSize: _compressedSize); } - return GetDataDecompressor(streamToDecompress); + + // Get decompressed stream + Stream decompressedStream = GetDataDecompressor(streamToDecompress); + + if (ForAesEncryption() && _aeVersion == 1) + { + // Wrap with CRC validator for AE-1 + return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); + } + + return decompressedStream; } private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) @@ -1024,7 +1030,6 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod password: password.AsMemory(), encrypting: true, keySizeBits: keysizebits, - ae2: true, leaveOpen: true); } @@ -1090,7 +1095,8 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st byte? aesStrength; ushort? originalCompressionMethod; ushort? aeVersion; - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion)) + uint? crc32; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion, out crc32)) { message = SR.LocalFileHeaderCorrupt; return false; @@ -1114,12 +1120,16 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st _aeVersion = aeVersion.Value; } - // CRITICAL: Store the actual compression method that will be used after decryption + if (crc32.HasValue && aeVersion == 1) + { + _crc32 = crc32.Value; + } + + // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly if (originalCompressionMethod.HasValue) { - // Temporarily set the compression method to the actual method for decompression - // Note: We're modifying _storedCompressionMethod, not the property + // Set the compression method to the actual method for decompression CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; } } 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 21d2a58da47238..8d9ce784004b11 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 @@ -671,11 +671,12 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion) + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion, out uint? crc32) { aesStrength = null; originalCompressionMethod = null; aesVersion = null; + crc32 = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) @@ -684,12 +685,16 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou { return false; // Not a valid local file header } + // Read fixed-size fields after signature // Local file header layout: // signature (4) + version (2) + flags (2) + compression (2) + // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + // uncompressed size (4) + name length (2) + extra length (2) - reader.ReadBytes(22); // Skip version through sizes + + reader.ReadBytes(10); // Skip version through mod date + crc32 = reader.ReadUInt32(); // Read CRC32 + reader.ReadBytes(8); // Skip compressed and uncompressed sizes ushort nameLength = reader.ReadUInt16(); ushort extraLength = reader.ReadUInt16(); @@ -721,7 +726,6 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou return true; } - } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index c2dc0b3653a429..34fed48cc94826 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -250,8 +250,6 @@ private byte DecryptByte(byte ciph) return plain; } - // ---- Stream overrides ---- - public override bool CanRead => !_encrypting; public override bool CanSeek => false; public override bool CanWrite => _encrypting; @@ -322,6 +320,16 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public override async ValueTask DisposeAsync() + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + await EnsureHeaderAsync(CancellationToken.None).ConfigureAwait(false); + if (!_leaveOpen) + await _base.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); @@ -356,7 +364,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella cancellationToken.ThrowIfCancellationRequested(); - EnsureHeader(); + await EnsureHeaderAsync(cancellationToken).ConfigureAwait(false); byte[] tmp = new byte[buffer.Length]; ReadOnlySpan span = buffer.Span; @@ -377,18 +385,6 @@ public override Task FlushAsync(CancellationToken cancellationToken) return _base.FlushAsync(cancellationToken); } - public override async ValueTask DisposeAsync() - { - // If encrypted empty entry (no payload written), still must emit 12-byte header: - if (_encrypting && !_headerWritten) - EnsureHeader(); - - if (!_leaveOpen) - await _base.DisposeAsync().ConfigureAwait(false); - - await base.DisposeAsync().ConfigureAwait(false); - } - private static uint Crc32Update(uint crc, byte b) => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } 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 fbbecefe676373..495ff7c7dd8502 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 @@ -679,4 +679,214 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } + + internal sealed class CrcValidatingReadStream : Stream + { + private readonly Stream _baseStream; + private uint _runningCrc; + private readonly uint _expectedCrc; + private long _totalBytesRead; + private readonly long _expectedLength; + private bool _isDisposed; + + public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) + { + _baseStream = baseStream; + _expectedCrc = expectedCrc; + _expectedLength = expectedLength; + _runningCrc = 0; + _totalBytesRead = 0; + } + + public override bool CanRead => !_isDisposed && _baseStream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + + public override long Length => _baseStream.Length; + + public override long Position + { + get => _baseStream.Position; + set => throw new NotSupportedException(SR.SeekingNotSupported); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + ValidateBufferArguments(buffer, offset, count); + + int bytesRead = _baseStream.Read(buffer, offset, count); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override int Read(Span buffer) + { + ThrowIfDisposed(); + + int bytesRead = _baseStream.Read(buffer); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Slice(0, bytesRead)); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ValidateBufferArguments(buffer, offset, count); + + int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Span.Slice(0, bytesRead)); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override void Write(ReadOnlySpan buffer) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override void Flush() + { + ThrowIfDisposed(); + _baseStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return _baseStream.FlushAsync(cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.SeekingNotSupported); + } + + public override void SetLength(long value) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); + } + + private void ValidateCrc() + { + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + // Validate CRC when stream is closed (if all data was read) + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + + _baseStream.Dispose(); + _isDisposed = true; + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + // Validate CRC when stream is closed (if all data was read) + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + + await _baseStream.DisposeAsync().ConfigureAwait(false); + _isDisposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } + } } From 8efa7729da1543604935d62a4978ffe124e3365a Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 20:33:24 +0100 Subject: [PATCH 18/83] Solve some comments --- .../System/IO/Compression/WinZipAesStream.cs | 612 +++++++++--------- .../IO/Compression/ZipArchiveEntry.Async.cs | 14 +- .../System/IO/Compression/ZipArchiveEntry.cs | 125 ++-- .../src/System/IO/Compression/ZipBlocks.cs | 108 +++- .../System/IO/Compression/ZipCryptoStream.cs | 143 +--- .../System/IO/Compression/ZipCustomStreams.cs | 3 +- 6 files changed, 447 insertions(+), 558 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index a7859dd26824cc..dad607aff1791a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -11,6 +11,7 @@ namespace System.IO.Compression { internal sealed class WinZipAesStream : Stream { + private const int BLOCK_SIZE = 16; // AES block size in bytes private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -19,7 +20,7 @@ internal sealed class WinZipAesStream : Stream #pragma warning disable CA1416 // HMACSHA1 is available on all platforms private readonly HMACSHA1 _hmac; #pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[16]; + private readonly byte[] _counterBlock = new byte[BLOCK_SIZE]; private byte[]? _key; private byte[]? _hmacKey; private byte[]? _salt; @@ -27,25 +28,23 @@ internal sealed class WinZipAesStream : Stream private bool _headerWritten; private bool _headerRead; private long _position; - private readonly ReadOnlyMemory _password; private bool _disposed; private bool _authCodeValidated; private readonly long _totalStreamSize; - private long _bytesReadFromBase; private readonly bool _leaveOpen; - private readonly MemoryStream? _encryptionBuffer; + private readonly long _encryptedDataSize; + private long _encryptedDataRemaining; + private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; + private int _partialBlockBytes; public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - _baseStream = baseStream; - _password = password; _encrypting = encrypting; _keySizeBits = keySizeBits; _totalStreamSize = totalStreamSize; // Store the total size - _bytesReadFromBase = 0; _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); @@ -62,19 +61,43 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; + if (_totalStreamSize > 0) + { + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + const int hmacSize = 10; // 10-byte HMAC + + _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + _encryptedDataRemaining = _encryptedDataSize; + } + else + { + _encryptedDataSize = -1; + _encryptedDataRemaining = -1; + } + if (_encrypting) { - _encryptionBuffer = new MemoryStream(); - GenerateKeys(); + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(password, _salt); + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; InitCipher(); } + else + { + ReadHeader(password); + } } - private void DeriveKeysFromPassword() + private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { - Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - - byte[] passwordBytes = Encoding.UTF8.GetBytes(_password.ToArray()); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password.ToArray()); try { @@ -85,7 +108,7 @@ private void DeriveKeysFromPassword() // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( passwordBytes, - _salt!, + salt, 1000, HashAlgorithmName.SHA1, totalKeySize); @@ -95,20 +118,12 @@ private void DeriveKeysFromPassword() _hmacKey = new byte[keySizeInBytes]; _passwordVerifier = new byte[2]; - // Copy the key material in the correct order - int offset = 0; - // First: AES encryption key - Buffer.BlockCopy(derivedKey, offset, _key, 0, _key.Length); - offset += _key.Length; - + derivedKey.AsSpan(0, _key.Length).CopyTo(_key); // Second: HMAC authentication key (same size as encryption key) - Buffer.BlockCopy(derivedKey, offset, _hmacKey, 0, _hmacKey.Length); - offset += _hmacKey.Length; - + derivedKey.AsSpan(_key.Length, _hmacKey.Length).CopyTo(_hmacKey); // Third: Password verification value (2 bytes) - Buffer.BlockCopy(derivedKey, offset, _passwordVerifier, 0, _passwordVerifier.Length); - + derivedKey.AsSpan(_key.Length + _hmacKey.Length).CopyTo(_passwordVerifier); // Clear the derived key from memory Array.Clear(derivedKey, 0, derivedKey.Length); } @@ -119,30 +134,44 @@ private void DeriveKeysFromPassword() } } - private void ValidateAuthCode() + private void ReadHeader(ReadOnlyMemory password) { - if (_encrypting || _authCodeValidated) - return; + if (_headerRead) return; - // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); - if (expectedAuth is not null) - { - // Read the 10-byte stored authentication code from the stream - byte[] storedAuth = new byte[10]; - _baseStream.ReadExactly(storedAuth); + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - // Compare the first 10 bytes of the expected hash - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException("Authentication code mismatch."); + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Derive keys from password and salt + DeriveKeysFromPassword(password, _salt); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); } - _authCodeValidated = true; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; } - private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (_encrypting || _authCodeValidated) return; @@ -155,7 +184,15 @@ private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) { // Read the 10-byte stored authentication code from the stream byte[] storedAuth = new byte[10]; - await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + + if (isAsync) + { + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.ReadExactly(storedAuth); + } // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) @@ -165,17 +202,14 @@ private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) _authCodeValidated = true; } - private void GenerateKeys() + private void ValidateAuthCode() { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(); + ValidateAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; + private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + { + return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); } private void InitCipher() @@ -186,14 +220,23 @@ private void InitCipher() _aesEncryptor = _aes.CreateEncryptor(); } - private void WriteHeader() + private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (_headerWritten) return; Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - _baseStream.Write(_salt); - _baseStream.Write(_passwordVerifier); + if (isAsync) + { + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + } + // output to debug log Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); @@ -201,59 +244,14 @@ private void WriteHeader() _headerWritten = true; } - private async Task WriteHeaderAsync(CancellationToken cancellationToken) + private void WriteHeader() { - if (_headerWritten) return; - Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); - await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); - // output to debug log - Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); - Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); - _headerWritten = true; + WriteHeaderCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); } - private void ReadHeader() + private Task WriteHeaderAsync(CancellationToken cancellationToken) { - if (_headerRead) return; - - // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - // Debug: Log the salt - Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - - // Read the 2-byte password verifier - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - // Debug: Log the verifier - Debug.WriteLine($"Password verifier: {BitConverter.ToString(verifier)}"); - - // Derive keys from password and salt - DeriveKeysFromPassword(); - - // Verify the password - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - - // Debug: Log derived verifier - Debug.WriteLine($"Derived verifier: {BitConverter.ToString(_passwordVerifier!)}"); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - { - throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); - } - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - Array.Clear(_counterBlock, 0, 16); - _counterBlock[0] = 1; - - _headerRead = true; + return WriteHeaderCoreAsync(isAsync: true, cancellationToken); } private void ProcessBlock(byte[] buffer, int offset, int count) @@ -263,11 +261,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) int processed = 0; byte[] keystream = new byte[16]; - // Log initial counter state - Debug.WriteLine($"=== ProcessBlock Debug ==="); - Debug.WriteLine($"Processing {count} bytes at offset {offset}"); - Debug.WriteLine($"Initial counter: {BitConverter.ToString(_counterBlock)}"); - while (processed < count) { _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); @@ -321,7 +314,7 @@ private void IncrementCounter() } } - private void WriteAuthCode() + private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (!_encrypting || _authCodeValidated) return; @@ -332,74 +325,32 @@ private void WriteAuthCode() if (authCode is not null) { // WinZip AES spec requires only the first 10 bytes of the HMAC - _baseStream.Write(authCode, 0, 10); - Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); - } - - _authCodeValidated = true; - } - - private async Task WriteAuthCodeAsync(CancellationToken cancellationToken) - { - if (!_encrypting || _authCodeValidated) - return; - - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; + if (isAsync) + { + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(authCode, 0, 10); + } - if (authCode is not null) - { - // WinZip AES spec requires only the first 10 bytes of the HMAC - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); } _authCodeValidated = true; } - private void WriteCore(ReadOnlySpan buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - WriteHeader(); - - // Buffer the data - don't process it yet - _encryptionBuffer?.Write(buffer); - - Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); - } - - - // Flush all buffered data, encrypt it, and write to base stream - private void FlushEncryptionBuffer() + private void WriteAuthCode() { - if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) - { - byte[] data = _encryptionBuffer.ToArray(); - ProcessBlock(data, 0, data.Length); - _baseStream.Write(data); - _position += data.Length; - _encryptionBuffer.SetLength(0); // Clear the buffer - } + WriteAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); } - // Replace the FlushEncryptionBufferAsync method with this version: - private async Task FlushEncryptionBufferAsync(CancellationToken cancellationToken) + private Task WriteAuthCodeAsync(CancellationToken cancellationToken) { - if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) - { - byte[] data = _encryptionBuffer.ToArray(); - ProcessBlock(data, 0, data.Length); - await _baseStream.WriteAsync(data, cancellationToken).ConfigureAwait(false); - _position += data.Length; - _encryptionBuffer.SetLength(0); // Clear the buffer - } + return WriteAuthCodeCoreAsync(isAsync: true, cancellationToken); } - private int ReadCore(Span buffer) + private async Task ReadCoreShared(Memory buffer, bool isAsync, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -407,109 +358,89 @@ private int ReadCore(Span buffer) throw new NotSupportedException("Stream is in encryption mode."); if (!_headerRead) - ReadHeader(); + throw new InvalidOperationException("Header must be read before reading data."); int bytesToRead = buffer.Length; // If we know the total size, ensure we don't read into the HMAC - if (_totalStreamSize > 0) + if (_encryptedDataSize > 0) { - // Calculate the size of the AES overhead - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - const int hmacSize = 10; // 10-byte HMAC - - // The actual encrypted data size is the total minus header and HMAC - long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; - long remainingData = encryptedDataSize - _bytesReadFromBase; - - if (remainingData <= 0) + if (_encryptedDataRemaining <= 0) { if (!_authCodeValidated) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } return 0; } - bytesToRead = (int)Math.Min(bytesToRead, remainingData); + bytesToRead = (int)Math.Min(bytesToRead, _encryptedDataRemaining); } if (bytesToRead == 0) { - if (!_authCodeValidated && _totalStreamSize > 0) + if (!_authCodeValidated && _encryptedDataSize > 0) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } return 0; } - int n = _baseStream.Read(buffer.Slice(0, bytesToRead)); - - Debug.WriteLine($"Read {n} bytes from base stream"); - - if (n > 0) + int bytesRead; + if (isAsync) { - _bytesReadFromBase += n; - - // Log the ciphertext before decryption - Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); + bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = _baseStream.Read(buffer.Span.Slice(0, bytesToRead)); + } - // The buffer now contains the ciphertext. - byte[] temp = buffer.Slice(0, n).ToArray(); + Debug.WriteLine($"Read {bytesRead} bytes from base stream"); - // 1. Update the HMAC with the ciphertext from `temp`. - // 2. Decrypt `temp` in-place. - ProcessBlock(temp, 0, n); + if (bytesRead > 0) + { + _encryptedDataRemaining -= bytesRead; - // Copy the decrypted data from `temp` back to the original buffer. - temp.CopyTo(buffer); + // Process the data - we need to copy because ProcessBlock modifies in-place + byte[] temp = buffer.Slice(0, bytesRead).ToArray(); + ProcessBlock(temp, 0, bytesRead); + temp.CopyTo(buffer.Span); - _position += n; + _position += bytesRead; } else // n == 0, meaning end of stream { if (!_authCodeValidated) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } } - return n; - } - - public override void Write(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); - } - - public override void Write(ReadOnlySpan buffer) - { - WriteCore(buffer); + return bytesRead; } - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - // Replace the WriteAsync method with this: - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + private int ReadCore(Span buffer) { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); + // Convert span to memory and call shared method synchronously + byte[] tempArray = new byte[buffer.Length]; + Memory memoryBuffer = tempArray.AsMemory(); - await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + int bytesRead = ReadCoreShared(memoryBuffer, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - // Just buffer the data - // The async version should match the sync version logic - await _encryptionBuffer!.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + // Copy the processed data back to the original span + memoryBuffer.Span.Slice(0, bytesRead).CopyTo(buffer); - Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); + return bytesRead; } public override int Read(byte[] buffer, int offset, int count) @@ -526,78 +457,149 @@ public override int Read(Span buffer) public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + return await ReadCoreShared(new Memory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(_disposed, this); + return await ReadCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + } - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); + private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - if (!_headerRead) - ReadHeader(); + // Write header if needed + if (!_headerWritten) + { + if (isAsync) + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + else + WriteHeader(); + } - // Apply the same boundary logic as ReadCore - int bytesToRead = buffer.Length; + int inputOffset = 0; + int inputCount = buffer.Length; - if (_totalStreamSize > 0) + // Fill the partial block buffer if it has data + if (_partialBlockBytes > 0) { - // Calculate the size of the AES overhead - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - const int hmacSize = 10; + int copyCount = Math.Min(BLOCK_SIZE - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); - // The actual encrypted data size is the total minus header and HMAC - long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; - long remainingData = encryptedDataSize - _bytesReadFromBase; + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= copyCount; - if (remainingData <= 0) + // If full, encrypt and write immediately + if (_partialBlockBytes == BLOCK_SIZE) { - if (!_authCodeValidated) - { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - } - return 0; - } + ProcessBlock(_partialBlock, 0, BLOCK_SIZE); + + if (isAsync) + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BLOCK_SIZE), cancellationToken).ConfigureAwait(false); + else + _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - bytesToRead = (int)Math.Min(bytesToRead, remainingData); + _position += BLOCK_SIZE; + _partialBlockBytes = 0; + } } - if (bytesToRead == 0) + // Process full blocks directly from the input + if (inputCount >= BLOCK_SIZE) { - if (!_authCodeValidated && _totalStreamSize > 0) + const int ChunkSize = 4096; + byte[] chunkBuffer = new byte[ChunkSize]; + + while (inputCount >= BLOCK_SIZE) { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - } - return 0; - } + // Round down to nearest multiple of 16 for the chunk + int bytesToProcess = Math.Min(inputCount, ChunkSize); + bytesToProcess = (bytesToProcess / BLOCK_SIZE) * BLOCK_SIZE; - int n = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + // Copy input to local buffer + buffer.Slice(inputOffset, bytesToProcess).CopyTo(chunkBuffer); - if (n > 0) - { - _bytesReadFromBase += n; + // Encrypt in-place + ProcessBlock(chunkBuffer, 0, bytesToProcess); - // Process the data - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer.Span); - _position += n; + // Write to stream + if (isAsync) + await _baseStream.WriteAsync(chunkBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); + else + _baseStream.Write(chunkBuffer, 0, bytesToProcess); + + _position += bytesToProcess; + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } } - else if (!_authCodeValidated) + + // Buffer any remaining bytes + if (inputCount > 0) { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + _partialBlockBytes += inputCount; } + } + + private void WriteCore(ReadOnlySpan buffer) + { + // Convert span to memory and call shared method synchronously + byte[] tempArray = buffer.ToArray(); + WriteCoreShared(tempArray, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } - return n; + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteCoreShared(new ReadOnlyMemory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await WriteCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + } + + + private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) + { + // Process any bytes remaining in the partial buffer + if (_partialBlockBytes > 0) + { + // Encrypt the partial block (ProcessBlock handles partials by XORing only available bytes) + ProcessBlock(_partialBlock, 0, _partialBlockBytes); + + if (isAsync) + { + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, _partialBlockBytes), cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(_partialBlock, 0, _partialBlockBytes); + } + + _position += _partialBlockBytes; + _partialBlockBytes = 0; + } } protected override void Dispose(bool disposing) { - if (_disposed) - return; + if (_disposed) return; if (disposing) { @@ -605,28 +607,17 @@ protected override void Dispose(bool disposing) { if (_encrypting && !_authCodeValidated && _headerWritten) { - // Encrypt all buffered data first - FlushEncryptionBuffer(); - - // Flush any pending data - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + // 1. Encrypt remaining partial data + FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - // Write the authentication code + // 2. Write Auth Code WriteAuthCode(); - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + if (_baseStream.CanWrite) _baseStream.Flush(); } else if (!_encrypting && !_authCodeValidated && _headerRead) { - // For decryption, validate auth code and CRC if not already done - ValidateAuthCode(); + ValidateAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); } } finally @@ -634,55 +625,36 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); - _encryptionBuffer?.Dispose(); + // Removed _encryptionBuffer.Dispose() - // Only dispose the base stream if we don't leave it open - if (!_leaveOpen) - { - _baseStream.Dispose(); - } + if (!_leaveOpen) _baseStream.Dispose(); } } _disposed = true; base.Dispose(disposing); } - - // Replace DisposeAsync with this version: public override async ValueTask DisposeAsync() { - if (_disposed) - return; + if (_disposed) return; try { if (_encrypting && !_authCodeValidated && _headerWritten) { - // make sure header is flushed - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + await _baseStream.FlushAsync().ConfigureAwait(false); - // Encrypt all buffered data first - await FlushEncryptionBufferAsync(CancellationToken.None).ConfigureAwait(false); + // 1. Encrypt remaining partial data + await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); - // Flush any pending data - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - } - - // Write the authentication code (this is still sync, which is fine) + // 2. Write Auth Code await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - } + if (_baseStream.CanWrite) await _baseStream.FlushAsync().ConfigureAwait(false); } else if (!_encrypting && !_authCodeValidated && _headerRead) { - // For decryption, validate auth code and CRC if not already done - await ValidateAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + await ValidateAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); } } finally @@ -690,18 +662,14 @@ public override async ValueTask DisposeAsync() _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); - _encryptionBuffer?.Dispose(); - // Only dispose the base stream if we don't leave it open - if (!_leaveOpen) - { - await _baseStream.DisposeAsync().ConfigureAwait(false); - } + if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } _disposed = true; GC.SuppressFinalize(this); } + public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; @@ -743,6 +711,7 @@ public override void Flush() _baseStream.Flush(); } } + public override async Task FlushAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -754,7 +723,6 @@ public override async Task FlushAsync(CancellationToken cancellationToken) { await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } - } // Finally flush base stream to ensure encrypted data is written diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 75d5f099c13863..876d9463da6240 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using static System.IO.Compression.ZipLocalFileHeader; namespace System.IO.Compression; @@ -154,17 +155,17 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Write WinZip AES extra field AFTER Zip64 (matching sync version order) // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader - if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { VendorVersion = 2, // AE-2 - AesStrength = _encryptionMethod switch + AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -309,6 +310,7 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW await _archive.ArchiveStream.WriteAsync(lfStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); + // Only when handling zip64 if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -316,17 +318,17 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Write WinZip AES extra field if using AES encryption // Must match the exact check used in the sync version WriteLocalFileHeader - if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { VendorVersion = 2, // AE-2 - AesStrength = _encryptionMethod switch + AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : 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 0b9ddb8799aa86..eaa52a32bd33c5 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 @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private CompressionMethodValues _aesCompressionLevel; + private CompressionMethodValues _aesCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -446,7 +446,7 @@ internal long GetOffsetOfCompressedData() else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; @@ -472,7 +472,6 @@ private MemoryStream GetUncompressedData(string? password = null) if (_originallyInArchive) { - if (_isEncrypted) { // We dont support edit-in-place for encrypted entries without an explicit password flow. @@ -486,7 +485,6 @@ private MemoryStream GetUncompressedData(string? password = null) "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); } - using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) { try @@ -612,7 +610,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 WinZipAesExtraField? aesExtraField = null; int aesExtraFieldSize = 0; - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { aesExtraField = new WinZipAesExtraField { @@ -622,7 +620,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -691,6 +689,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); @@ -719,7 +718,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption (add this block) - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { @@ -729,7 +728,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -886,14 +885,12 @@ private bool IsZipCryptoEncrypted() private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _aesCompressionLevel == CompressionMethodValues.Aes; + return _aesCompressionMethod == CompressionMethodValues.Aes; } private bool ForAesEncryption() { - return _encryptionMethod == EncryptionMethod.Aes128 || - _encryptionMethod == EncryptionMethod.Aes192 || - _encryptionMethod == EncryptionMethod.Aes256; + return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } private Stream GetDataDecompressor(Stream compressedStreamToRead) @@ -1008,7 +1005,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } - else if (encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128) + else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("Password is required for encryption."); @@ -1035,7 +1032,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128 ? false : true, + encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1091,46 +1088,33 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionLevel = CompressionMethodValues.Aes; - byte? aesStrength; - ushort? originalCompressionMethod; - ushort? aeVersion; - uint? crc32; - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion, out crc32)) + _aesCompressionMethod = CompressionMethodValues.Aes; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { message = SR.LocalFileHeaderCorrupt; return false; } - if (aesStrength.HasValue) + if (aesExtraField.HasValue) { - EncryptionMethod detectedEncryption = aesStrength switch { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - } - if (aeVersion.HasValue) - { - _aeVersion = aeVersion.Value; - } - - if (crc32.HasValue && aeVersion == 1) - { - _crc32 = crc32.Value; - } + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + } + _aeVersion = aesExtraField.Value.VendorVersion; - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - if (originalCompressionMethod.HasValue) - { + // Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; } } @@ -1262,42 +1246,6 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } - internal sealed class WinZipAesExtraField - { - public const ushort HeaderId = 0x9901; - - public ushort VendorVersion { get; set; } = 2; // AE-2 - public byte AesStrength { get; set; } // 1=128bit, 2=192bit, 3=256bit - public ushort CompressionMethod { get; set; } // Original compression method - - public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) - - public void WriteBlock(Stream stream) - { - Span buffer = new byte[TotalSize]; - WriteBlockCore(buffer); - stream.Write(buffer); - } - - public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) - { - byte[] buffer = new byte[TotalSize]; - WriteBlockCore(buffer); - await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - } - - private void WriteBlockCore(Span buffer) - { - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); - buffer[6] = (byte)'A'; - buffer[7] = (byte)'E'; - buffer[8] = AesStrength; - - BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); - } - } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and @@ -1345,19 +1293,18 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : (ushort)CompressionMethodValues.Deflate }; - aesExtraFieldSize = 11; + aesExtraFieldSize = WinZipAesExtraField.TotalSize; } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header @@ -1444,6 +1391,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); @@ -1468,17 +1416,16 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -1647,6 +1594,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCrc32Location = ZipLocalFileHeader.FieldLocations.Crc32 - ZipLocalFileHeader.FieldLocations.Crc32; int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); @@ -1675,7 +1623,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) int bytesToWrite; ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); 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 8d9ce784004b11..893e03cab06c95 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 @@ -671,62 +671,108 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion, out uint? crc32) + public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? aesExtraField) { - aesStrength = null; - originalCompressionMethod = null; - aesVersion = null; - crc32 = null; + aesExtraField = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) byte[] signatureBytes = reader.ReadBytes(4); - if (!signatureBytes.AsSpan().SequenceEqual(ZipLocalFileHeader.SignatureConstantBytes)) + if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) { return false; // Not a valid local file header } // Read fixed-size fields after signature - // Local file header layout: - // signature (4) + version (2) + flags (2) + compression (2) + - // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + - // uncompressed size (4) + name length (2) + extra length (2) - - reader.ReadBytes(10); // Skip version through mod date - crc32 = reader.ReadUInt32(); // Read CRC32 - reader.ReadBytes(8); // Skip compressed and uncompressed sizes + // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) + reader.ReadBytes(22); // Skip 22 bytes total + ushort nameLength = reader.ReadUInt16(); ushort extraLength = reader.ReadUInt16(); // Skip file name stream.Seek(nameLength, SeekOrigin.Current); - // Parse extra fields - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - while (stream.Position < extraEnd) + // Parse extra fields if present + if (extraLength > 0) { - ushort headerId = reader.ReadUInt16(); - ushort dataSize = reader.ReadUInt16(); + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; - if (headerId == 0x9901) // AES extra field - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - aesVersion = reader.ReadUInt16(); - reader.ReadBytes(2); // Vendor ID - aesStrength = reader.ReadByte(); // 1, 2, or 3 - originalCompressionMethod = reader.ReadUInt16(); - } - else + while (stream.Position < extraEnd) { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + ushort headerId = reader.ReadUInt16(); + ushort dataSize = reader.ReadUInt16(); + + if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + ushort vendorVersion = reader.ReadUInt16(); + reader.ReadBytes(2); // Skip vendor ID 'AE' + byte aesStrength = reader.ReadByte(); // 1, 2, or 3 + ushort compressionMethod = reader.ReadUInt16(); + + aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); + break; + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } } } return true; } } + internal struct WinZipAesExtraField + { + public const ushort HeaderId = 0x9901; + private ushort _vendorVersion = 2; + private byte _aesStrength; + private ushort _compressionMethod; + + public WinZipAesExtraField(ushort VendorVersion, byte AesStrength, ushort CompressionMethod) + { + this.VendorVersion = VendorVersion; + this.AesStrength = AesStrength; + this.CompressionMethod = CompressionMethod; + } + + public ushort VendorVersion { get => _vendorVersion; set => _vendorVersion = value; } + public byte AesStrength { get => _aesStrength; set => _aesStrength = value; } // 1=128bit, 2=192bit, 3=256bit + public ushort CompressionMethod { get => _compressionMethod; set => _compressionMethod = value; } // Original compression method + + public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + + public void WriteBlock(Stream stream) + { + Span buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + stream.Write(buffer); + } + + public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + private void WriteBlockCore(Span buffer) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); + buffer[6] = (byte)'A'; + buffer[7] = (byte)'E'; + buffer[8] = AesStrength; + + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); + } + } + internal sealed partial class ZipCentralDirectoryFileHeader { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 34fed48cc94826..8cd53d2b08fe6f 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -35,18 +36,7 @@ private static uint[] CreateCrc32Table() return table; } - // Private constructor for async factory method - private ZipCryptoStream(Stream baseStream, bool encrypting, bool leaveOpen = false, ushort verifierLow2Bytes = 0, uint? crc32ForHeader = null) - { - _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - _encrypting = encrypting; - _leaveOpen = leaveOpen; - _verifierLow2Bytes = verifierLow2Bytes; - _crc32ForHeader = crc32ForHeader; - _position = 0; - } - - // Synchronous decryption constructor (existing) + // Decryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -56,7 +46,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex _position = 0; } - // Synchronous encryption constructor (existing) + // Encryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, @@ -72,114 +62,66 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } - // Async factory method for decryption - public static async Task CreateForDecryptionAsync( - Stream baseStream, - ReadOnlyMemory password, - byte expectedCheckByte, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseStream); - - var stream = new ZipCryptoStream(baseStream, encrypting: false); - stream.InitKeysFromBytes(password.Span); - await stream.ValidateHeaderAsync(expectedCheckByte, cancellationToken).ConfigureAwait(false); - return stream; - } - - // Async factory method for encryption - public static Task CreateForEncryptionAsync( - Stream baseStream, - ReadOnlyMemory password, - ushort passwordVerifierLow2Bytes, - uint? crc32 = null, - bool leaveOpen = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseStream); - cancellationToken.ThrowIfCancellationRequested(); - - var stream = new ZipCryptoStream(baseStream, encrypting: true, leaveOpen, passwordVerifierLow2Bytes, crc32); - stream.InitKeysFromBytes(password.Span); - - // No async work needed for encryption constructor - return Task.FromResult(stream); - } - - private void EnsureHeader() + private byte[] CalculateHeader() { - if (!_encrypting || _headerWritten) return; - - Span hdrPlain = stackalloc byte[12]; + byte[] hdrPlain = new byte[12]; // bytes 0..9 are random - RandomNumberGenerator.Fill(hdrPlain.Slice(0, 10)); + RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) if (_crc32ForHeader.HasValue) { uint crc = _crc32ForHeader.Value; - hdrPlain[10] = (byte)((crc >> 16) & 0xFF); - hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), (ushort)(crc >> 16)); } else { - hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); + BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), _verifierLow2Bytes); } - // Encrypt & write; update keys with PLAINTEXT per spec + // Update keys with PLAINTEXT per spec byte[] hdrCiph = new byte[12]; for (int i = 0; i < 12; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = hdrPlain[i]; hdrCiph[i] = (byte)(p ^ ks); UpdateKeys(p); } - _base.Write(hdrCiph, 0, 12); - _headerWritten = true; - _position += 12; + return hdrCiph; } - private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + private async ValueTask WriteHeaderCore(bool isAsync, CancellationToken cancellationToken = default) { if (!_encrypting || _headerWritten) return; - byte[] hdrPlain = new byte[12]; + byte[] hdrCiph = CalculateHeader(); - // bytes 0..9 are random - RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); - - // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) - if (_crc32ForHeader.HasValue) + if (isAsync) { - uint crc = _crc32ForHeader.Value; - hdrPlain[10] = (byte)((crc >> 16) & 0xFF); - hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); } else { - hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); - } - - // Encrypt & write; update keys with PLAINTEXT per spec - byte[] hdrCiph = new byte[12]; - for (int i = 0; i < 12; i++) - { - byte ks = DecipherByte(); - byte p = hdrPlain[i]; - hdrCiph[i] = (byte)(p ^ ks); - UpdateKeys(p); + _base.Write(hdrCiph, 0, 12); } - await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); _headerWritten = true; _position += 12; } + private void EnsureHeader() + { + WriteHeaderCore(isAsync: false).AsTask().GetAwaiter().GetResult(); + } + + private ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + { + return WriteHeaderCore(isAsync: true, cancellationToken); + } + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; @@ -195,30 +137,13 @@ private void InitKeysFromBytes(ReadOnlySpan password) private void ValidateHeader(byte expectedCheckByte) { byte[] hdr = new byte[12]; - int read = 0; - while (read < hdr.Length) + try { - int n = _base.Read(hdr, read, hdr.Length - read); - if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); - read += n; + _base.ReadExactly(hdr); } - - for (int i = 0; i < hdr.Length; i++) - hdr[i] = DecryptByte(hdr[i]); - - if (hdr[11] != expectedCheckByte) - throw new InvalidDataException("Invalid password for encrypted ZIP entry."); - } - - private async ValueTask ValidateHeaderAsync(byte expectedCheckByte, CancellationToken cancellationToken) - { - byte[] hdr = new byte[12]; - int read = 0; - while (read < hdr.Length) + catch (EndOfStreamException) { - int n = await _base.ReadAsync(hdr.AsMemory(read, hdr.Length - read), cancellationToken).ConfigureAwait(false); - if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); - read += n; + throw new InvalidDataException("Truncated ZipCrypto header."); } for (int i = 0; i < hdr.Length; i++) @@ -236,7 +161,7 @@ private void UpdateKeys(byte b) _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); } - private byte DecipherByte() + private byte Decrypt() { uint temp = _key2 | 2; // use uint to avoid narrowing issues return (byte)((temp * (temp ^ 1)) >> 8); @@ -244,7 +169,7 @@ private byte DecipherByte() private byte DecryptByte(byte ciph) { - byte m = DecipherByte(); + byte m = Decrypt(); byte plain = (byte)(ciph ^ m); UpdateKeys(plain); return plain; @@ -297,7 +222,7 @@ public override void Write(ReadOnlySpan buffer) byte[] tmp = new byte[buffer.Length]; for (int i = 0; i < buffer.Length; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = buffer[i]; tmp[i] = (byte)(p ^ ks); UpdateKeys(p); @@ -370,7 +295,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella ReadOnlySpan span = buffer.Span; for (int i = 0; i < buffer.Length; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = span[i]; tmp[i] = (byte)(p ^ ks); UpdateKeys(p); 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 495ff7c7dd8502..2a6a47b1a726e9 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 @@ -862,8 +862,7 @@ protected override void Dispose(bool disposing) // Validate CRC when stream is closed (if all data was read) if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + throw new InvalidDataException("CRC mismatch"); } _baseStream.Dispose(); From d5bb294dc684dc1cf236c4e3b02e585844f4b5d7 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 22:27:13 +0100 Subject: [PATCH 19/83] refactor tests and ci disable local ones --- ...System.IO.Compression.ZipFile.Tests.csproj | 33 +- .../tests/ZipFile.Encryption.cs | 328 +++++++ .../tests/ZipFile.Extract.cs | 801 +----------------- 3 files changed, 377 insertions(+), 785 deletions(-) create mode 100644 src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj index 12005f1aae45aa..d1fe53d93af88e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj @@ -1,4 +1,4 @@ - + true true @@ -16,32 +16,23 @@ + - - - - - - - - - - + + + + + + + + + + diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs new file mode 100644 index 00000000000000..a0b7432a9d4dd0 --- /dev/null +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -0,0 +1,328 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + public class ZipFile_EncryptionTests : ZipFileTestBase + { + public static IEnumerable Get_SingleEntry_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes128, + ZipArchiveEntry.EncryptionMethod.Aes192, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MultipleEntries_SamePassword_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MultipleEntries_DifferentPasswords_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MixedPlainEncrypted_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes128, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_Is_Async() + { + yield return new object[] { false }; + yield return new object[] { true }; + } + + [Theory] + [MemberData(nameof(Get_SingleEntry_Data))] + public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "Secret Content"; + string password = "password123"; + + var entries = new[] { (entryName, content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + await AssertEntryTextEquals(entry, content, password, async); + } + } + + [Theory] + [MemberData(nameof(Get_MultipleEntries_SamePassword_Data))] + public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "SharedPassword"; + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("folder/file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Theory] + [MemberData(nameof(Get_MultipleEntries_DifferentPasswords_Data))] + public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("file1.txt", "Content 1", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Theory] + [MemberData(nameof(Get_MixedPlainEncrypted_Data))] + public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)"pass", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Check plain + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + await AssertEntryTextEquals(plainEntry, "Plain Content", null, async); + + // Check encrypted + var encEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encEntry); + await AssertEntryTextEquals(encEntry, "Encrypted Content", "pass", async); + } + } + + [Theory] + [MemberData(nameof(Get_Is_Async))] + public async Task Encryption_Combinations_RoundTrip(bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("zipcrypto.txt", "ZipCrypto Content", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("aes128.txt", "AES128 Content", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes256.txt", "AES256 Content", (string?)"pass3", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Fact] + public void Negative_WrongPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("test.txt"); + Assert.Throws(() => entry.Open("wrong")); + } + } + + [Fact] + public void Negative_MissingPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("test.txt"); + Assert.Throws(() => entry.Open()); + } + } + + [Fact] + public void Negative_OpeningPlainEntryWithPassword_Throws() + { + string archivePath = GetTempArchivePath(); + var entries = new[] { ("plain.txt", "content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("plain.txt"); + Assert.ThrowsAny(() => entry.Open("password")); + } + } + + [Theory] + [MemberData(nameof(Get_Is_Async))] + // todo: some async methods in ziparchiveentry missing implementation for winzipaesstream + public async Task ExtractToFile_Encrypted_Success(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "pass"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + var entry = archive.GetEntry("test.txt"); + string destFile = GetTestFilePath(); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + Assert.Equal("content", await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: true, password: password); + Assert.Equal("content", File.ReadAllText(destFile)); + } + } + } + + private string GetTempArchivePath() => GetTestFilePath(); + + private async Task CreateArchiveWithEntries(string archivePath, (string Name, string Content, string? Password, ZipArchiveEntry.EncryptionMethod? Encryption)[] entries, bool async) + { + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + foreach (var (name, content, password, encryption) in entries) + { + ZipArchiveEntry entry = archive.CreateEntry(name); + Stream s; + if (password != null && encryption.HasValue) + { + s = entry.Open(password, encryption.Value); + } + else + { + s = await OpenEntryStream(async, entry); + } + + using (s) + using (StreamWriter w = new StreamWriter(s, Encoding.UTF8)) + { + if (async) + await w.WriteAsync(content); + else + w.Write(content); + } + } + } + } + + private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, string? password, bool async) + { + Stream s; + if (password != null) + { + s = entry.Open(password); + } + else + { + s = await OpenEntryStream(async, entry); + } + + using (s) + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + { + string actual; + if (async) + actual = await r.ReadToEndAsync(); + else + actual = r.ReadToEnd(); + + Assert.Equal(expected, actual); + } + } + } +} diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 1e46b8bf8d08a1..8e46556db19b88 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -218,6 +218,7 @@ public async Task DirectoryEntryWithData(bool async) } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenEncryptedTxtFile_ShouldReturnPlaintext() { @@ -232,7 +233,7 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() Assert.Equal("Hello ZipCrypto!", content); } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() { @@ -259,6 +260,7 @@ public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() File.Delete(tempFile); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { @@ -277,6 +279,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() }); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() { @@ -296,7 +299,6 @@ public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() }); } - [Fact] public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() { @@ -318,6 +320,7 @@ public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() File.Delete(tempFile); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() { @@ -336,7 +339,7 @@ await Assert.ThrowsAsync(async () => }); } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ExtractToFileAsync_WithoutPassword_ShouldThrow() { @@ -355,50 +358,6 @@ await Assert.ThrowsAsync(async () => }); } - [Fact] - public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_async_cancel.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); // Cancel immediately - await Assert.ThrowsAsync(async () => - { - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789", cts.Token); - }); - } - - [Fact] - public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() - { - // Arrange - string zipPath = @"C:\Users\spahontu\Downloads\jpg.zip"; - string originalPath = @"C:\Users\spahontu\Downloads\test.jpg"; // original JPEG for comparison - Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); - Assert.True(File.Exists(originalPath), $"Original JPEG not found at {originalPath}"); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - - using var stream = entry.Open("123456789"); - - using var ms = new MemoryStream(); - stream.CopyTo(ms); - byte[] actualBytes = ms.ToArray(); - - byte[] expectedBytes = File.ReadAllBytes(originalPath); - Assert.Equal(expectedBytes.Length, actualBytes.Length); - Assert.Equal(expectedBytes, actualBytes); - } - - [Fact] public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() { @@ -439,6 +398,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDecryptBoth() { @@ -479,8 +439,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec } } - - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() { @@ -524,8 +483,6 @@ public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() Assert.Equal(expectedContent, actualContent); } - - [Fact] public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() { @@ -568,6 +525,7 @@ public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() { @@ -619,6 +577,7 @@ public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() { @@ -693,6 +652,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() { @@ -732,6 +692,7 @@ public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() { @@ -775,6 +736,7 @@ public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() { @@ -813,6 +775,7 @@ public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() { @@ -871,6 +834,7 @@ public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() Assert.Equal(asyncContent, actualAsyncContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() { @@ -921,6 +885,7 @@ public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() Assert.Equal(expectedData, actualData); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() { @@ -963,677 +928,7 @@ public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() Assert.Equal(expectedData, actualData); } - [Fact] - public async Task ZipCrypto_AsyncWithWrongPassword_ThrowsInvalidDataException() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_wrong_pw_async.zip"); - const string entryName = "secure.txt"; - const string correctPassword = "Correct123"; - const string wrongPassword = "Wrong123"; - const string content = "Secret content"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName); - using var writer = new StreamWriter(entry.Open(correctPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await writer.WriteAsync(content); - } - - // Act & Assert - Try to read with wrong password - await Assert.ThrowsAsync(async () => - { - using var za = ZipFile.Open(zipPath, ZipArchiveMode.Read); - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var stream = entry!.Open(wrongPassword); - byte[] buffer = new byte[100]; - await stream.ReadAsync(buffer, 0, buffer.Length); - }); - } - - [Fact] - public async Task Update_AddEncryptedEntry_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("update_add.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial archive with one plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("plain.txt"); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("plain content"); - } - - // Act: Open in Update mode and add encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var encEntry = za.CreateEntry("secure/new.txt"); - using var w = new StreamWriter(encEntry.Open("pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await w.WriteAsync("secret data"); - } - - // Assert: Verify both entries exist and encrypted one decrypts correctly - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var plain = za.GetEntry("plain.txt"); - Assert.NotNull(plain); - using (var r = new StreamReader(plain!.Open(), Encoding.UTF8)) - Assert.Equal("plain content", await r.ReadToEndAsync()); - - var secure = za.GetEntry("secure/new.txt"); - Assert.NotNull(secure); - using (var r = new StreamReader(secure!.Open("pw123"), Encoding.UTF8)) - Assert.Equal("secret data", await r.ReadToEndAsync()); - } - } - - [Fact] - public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() - { - // Arrange - string zipPath = NewPath("update_delete.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/delete.txt"); - using var w = new StreamWriter(e.Open("delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await w.WriteAsync("to be deleted"); - } - - // Act: Delete the encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/delete.txt"); - Assert.NotNull(e); - e!.Delete(); - } - - // Assert: Entry should not exist - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - Assert.Null(za.GetEntry("secure/delete.txt")); - } - } - - [Fact] - public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() - { - // Arrange - string zipPath = NewPath("update_copy.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string pw = "copy-pw"; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/original.txt"); - using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - w.Write("original content"); - } - - // Act: Copy encrypted entry to new name - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry("secure/original.txt"); - Assert.NotNull(src); - - // Read original - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - content = r.ReadToEnd(); - - // Create new entry with same password - var dst = za.CreateEntry("secure/copy.txt"); - using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - w.Write(content); - } - - // Assert: Both entries exist and decrypt correctly - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var orig = za.GetEntry("secure/original.txt"); - var copy = za.GetEntry("secure/copy.txt"); - Assert.NotNull(orig); - Assert.NotNull(copy); - - using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8)) - Assert.Equal("original content", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8)) - Assert.Equal("original content", await r2.ReadToEndAsync()); - } - } - - - [Fact] - public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("update_copy.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string pw = "copy-pw"; - const string originalName = "secure/original.txt"; - const string copyName = "secure/copy.txt"; - const string payload = "original content"; - - // Create archive and a single encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry(originalName); - using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(payload); - } - - // Act: Open in Update mode and copy encrypted entry to a new name - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry(originalName); - Assert.NotNull(src); - - // READ-ONLY decrypt in Update mode (Option A): Open(password) returns a readable stream, - // does NOT mark the entry as modified, and does NOT materialize to an edit buffer. - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - content = await r.ReadToEndAsync(); - - // Optional: wrong password should fail early - Assert.ThrowsAny(() => - { - using var _ = src.Open("WRONG"); - }); - - // Create the destination entry with the same password and write the copied content. - var dst = za.CreateEntry(copyName); - using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(content); - } - - // Assert: Both entries exist and decrypt to the expected content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var orig = za.GetEntry(originalName); - var copy = za.GetEntry(copyName); - Assert.NotNull(orig); - Assert.NotNull(copy); - - using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - var text = await r1.ReadToEndAsync(); - Assert.Equal(payload, text); - } - - using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - var text = await r2.ReadToEndAsync(); - Assert.Equal(payload, text); - } - } - } - - - //[Fact] - //public void Update_OpenEncryptedEntry_WrongPassword_Throws() - //{ - // string zipPath = NewPath("update_wrong_pw.zip"); - // const string pw = "correct-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // w.Write("secret"); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/file.txt"); - // Assert.NotNull(e); - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open("wrong-pw"); - // }); - // } - //} - - - //[Fact] - //public async Task Update_EditPlainEntry_RoundTrip() - //{ - // string zipPath = NewPath("update_edit_plain.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create plain entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("plain.txt"); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("original"); - // } - - // // Edit in Update mode - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("plain.txt"); - // Assert.NotNull(e); - - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Verify updated content - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("plain.txt"); - // using var r = new StreamReader(e.Open(), Encoding.UTF8); - // Assert.Equal("modified", await r.ReadToEndAsync()); - // } - //} - - - - //[Fact] - //public void Update_EditEncryptedEntryWithoutPassword_Throws() - //{ - // string zipPath = NewPath("update_edit_encrypted.zip"); - // const string pw = "edit-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // w.Write("secret"); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/edit.txt"); - // Assert.NotNull(e); - - // // Should throw because edit-in-place for encrypted entries is not supported - // Assert.Throws(() => - // { - // using var _ = e.Open(); // no password - // }); - // } - //} - - - //[Fact] - //public async Task Update_MixedEntries_ReadEncrypted_EditPlain() - //{ - // string zipPath = NewPath("update_mixed.zip"); - // const string pw = "mixed-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create initial zip with encrypted and plain entries - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) - // await w.WriteAsync("encrypted"); - - // var plainEntry = za.CreateEntry("plain.txt"); - // using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) - // await w.WriteAsync("original"); - // } - - // // First update: read encrypted, modify plain - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var enc = za.GetEntry("secure/data.txt"); - // Assert.NotNull(enc); - - // string encryptedContent; - // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - // encryptedContent = await r.ReadToEndAsync(); - - // var plain = za.GetEntry("plain.txt"); - // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Second update: verify encrypted, re-modify plain - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var enc = za.GetEntry("secure/data.txt"); - // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - // Assert.Equal("encrypted", await r.ReadToEndAsync()); - - // var plain = za.GetEntry("plain.txt"); - // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Final read: verify both entries - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) - // Assert.Equal("encrypted", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) - // Assert.Equal("modified", await r2.ReadToEndAsync()); - // } - //} - - - - //[Fact] - //public async Task Update_ModifySameEncryptedEntryMultipleTimes() - //{ - // string zipPath = NewPath("update_modify_multiple.zip"); - // const string pw = "multi-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create initial encrypted entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("version1"); - // } - - // // Modify entry multiple times - // for (int i = 2; i <= 3; i++) - // { - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/data.txt"); - // Assert.NotNull(e); - - // string oldContent; - // using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) - // oldContent = await r.ReadToEndAsync(); - - // e.Delete(); // remove old entry - - // var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); - // await w.WriteAsync($"{oldContent}-version{i}"); - // } - // } - - // // Assert final content - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("secure/data.txt"); - // Assert.NotNull(e); - // using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); - // var text = await r.ReadToEndAsync(); - // Assert.Equal("version1-version2-version3", text); - // } - //} - - - //[Fact] - //public async Task Update_CopyEncryptedEntryToPlainEntry() - //{ - // string zipPath = NewPath("update_copy_to_plain.zip"); - // const string pw = "plain-copy"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create encrypted entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("secret content"); - // } - - // // Copy encrypted content to a plain entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var src = za.GetEntry("secure/original.txt"); - // Assert.NotNull(src); - - // string content; - // using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - // content = await r.ReadToEndAsync(); - - // var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption - // using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); - // await w.WriteAsync(content); - // } - - // // Assert both entries exist and content matches - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var enc = za.GetEntry("secure/original.txt"); - // var plain = za.GetEntry("public/copy.txt"); - // Assert.NotNull(enc); - // Assert.NotNull(plain); - - // using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) - // Assert.Equal("secret content", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) - // Assert.Equal("secret content", await r2.ReadToEndAsync()); - // } - //} - - - //[Fact] - //public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() - //{ - // // Arrange - // Directory.CreateDirectory(DownloadsDir); - // string srcPath = NewPath("source_wrong_pw.txt"); - // string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); - // const string entryName = "secure/wrong.txt"; - // const string correctPassword = "correct!"; - // const string badPassword = "wrong!"; - // const string payload = "secret data"; - - // if (File.Exists(srcPath)) File.Delete(srcPath); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntryFromFile( - // sourceFileName: srcPath, - // entryName: entryName, - // compressionLevel: CompressionLevel.Optimal, - // password: correctPassword, - // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // } - - // // Act & Assert - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - - // Assert.ThrowsAny(() => - // { - // using var _ = e!.Open(badPassword); - // }); - // } - //} - - - //[Fact] - //public async Task CreateEntryFromFile_WithEncryption_RoundTrip() - //{ - // // Arrange - // Directory.CreateDirectory(DownloadsDir); - // string srcPath = NewPath("source_plain.txt"); - // string zipPath = NewPath("create_from_file_plain.zip"); - // const string entryName = "plain/copy.txt"; - // const string payload = "this is plain"; - // const string pwd = "anything"; - - // if (File.Exists(srcPath)) File.Delete(srcPath); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntryFromFile( - // sourceFileName: srcPath, - // entryName: entryName, - // compressionLevel: CompressionLevel.Optimal, - // password: pwd, - // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - - // using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - // string text = await r.ReadToEndAsync(); - // Assert.Equal(payload, text); - - // // Opening a plain entry with a password should throw - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open("some-password"); - // }); - // } - //} - - //[Fact] - //public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() - //{ - // Directory.CreateDirectory(DownloadsDir); - // var zipPath = NewPath("defaults_apply.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string defaultPassword = "archive-pw"; - // const string payload = "default encryption content"; - // const string entryName = "secure/default.txt"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: defaultPassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e = za.CreateEntry(entryName); - - // using (var es = e.Open()) - // { - // var bytes = Encoding.UTF8.GetBytes(payload); - // es.Write(bytes, 0, bytes.Length); - // } - // } - - // // Verify with the archive default password - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - // using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); - // Assert.Equal(payload, r.ReadToEnd()); - // } - //} - - //[Fact] - //public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() - //{ - // string zipPath = NewPath("defaults_multiple.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string defaultPassword = "archive-pw"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: defaultPassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e1 = za.CreateEntry("secure/one.txt"); - // using (var s1 = e1.Open()) - // { - // var b = Encoding.UTF8.GetBytes("ONE"); - // s1.Write(b, 0, b.Length); - // } - - // var e2 = za.CreateEntry("secure/two.txt"); - // using (var s2 = e2.Open()) - // { - // var b = Encoding.UTF8.GetBytes("TWO"); - // s2.Write(b, 0, b.Length); - // } - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) - // Assert.Equal("ONE", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) - // Assert.Equal("TWO", await r2.ReadToEndAsync()); - // } - //} - - //[Fact] - //public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() - //{ - // string zipPath = NewPath("override_default.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string archivePassword = "archive-pw"; - // const string entryPassword = "entry-pw"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: archivePassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using (var s = e.Open()) - // { - // var b = Encoding.UTF8.GetBytes("OVERRIDE"); - // s.Write(b, 0, b.Length); - // } - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("secure/override.txt"); - // Assert.NotNull(e); - - // // Should succeed with entry password - // using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) - // Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); - - // // Wrong: using archive default should fail - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open(archivePassword); - // }); - // } - //} - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() { @@ -1648,6 +943,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() { @@ -1662,6 +958,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() { @@ -1676,6 +973,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() { @@ -1701,6 +999,7 @@ public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() { @@ -1738,6 +1037,7 @@ public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() { @@ -1791,6 +1091,7 @@ public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() { @@ -1838,6 +1139,7 @@ public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() { @@ -1911,6 +1213,7 @@ public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() { @@ -1949,6 +1252,7 @@ public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() { @@ -1986,6 +1290,7 @@ public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() { @@ -2036,6 +1341,7 @@ public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void CreateLargeFileWithAES128_RoundTrip() { @@ -2077,6 +1383,7 @@ public void CreateLargeFileWithAES128_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateCompressedAndAES192Encrypted_RoundTrip() { @@ -2132,6 +1439,7 @@ public async Task CreateCompressedAndAES192Encrypted_RoundTrip() Assert.True(fileInfo.Length > 0); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MixAllEncryptionTypes_RoundTrip() { @@ -2199,6 +1507,7 @@ public async Task MixAllEncryptionTypes_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() { @@ -2220,6 +1529,7 @@ public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task AES128WithSpecialCharacters_RoundTrip() { @@ -2307,6 +1617,7 @@ public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() { @@ -2362,6 +1673,7 @@ public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() { @@ -2413,48 +1725,7 @@ public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() } } - [Fact] - public async Task StreamCopyAsyncWithAES192_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes192_stream_copy.zip"); - const string entryName = "stream_copy.dat"; - const string password = "StreamCopy192!"; - - // Create test data - var testData = new byte[64 * 1024]; // 64KB - new Random(456).NextBytes(testData); - - // Act 1: Use CopyToAsync to write - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); - using var sourceStream = new MemoryStream(testData); - - await sourceStream.CopyToAsync(entryStream); - } - - // Act 2: Use CopyToAsync to read - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var destStream = new MemoryStream(); - - await entryStream.CopyToAsync(destStream); - var actualData = destStream.ToArray(); - - // Assert - Assert.Equal(testData, actualData); - } - } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() { @@ -2505,6 +1776,7 @@ public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task AsyncReadInChunks_AES128_VerifyContent() { @@ -2555,6 +1827,7 @@ public async Task AsyncReadInChunks_AES128_VerifyContent() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MixedSyncAsyncOperations_AES192_RoundTrip() { From 0c659fcd36d30b5ecdcff385646c39c2aa68f90c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 22:46:01 +0100 Subject: [PATCH 20/83] improve winzipaesstream writing by generating larger keystream --- .../tests/ZipFile.Encryption.cs | 44 +++++++++++ .../System/IO/Compression/WinZipAesStream.cs | 75 +++++++++++++------ 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index a0b7432a9d4dd0..dad9cd96be8ead 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -197,6 +197,50 @@ public async Task Encryption_Combinations_RoundTrip(bool async) } } + [Theory] + [MemberData(nameof(Get_Is_Async))] + public async Task Encryption_LargeFile_RoundTrip(bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "large.bin"; + int size = 1024 * 1024; // 1MB + byte[] content = new byte[size]; + new Random(42).NextBytes(content); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + Stream s = entry.Open(password, encryptionMethod); + using (s) + { + if (async) + await s.WriteAsync(content, 0, content.Length); + else + s.Write(content, 0, content.Length); + } + } + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + Stream s = entry.Open(password); + using (s) + using (MemoryStream ms = new MemoryStream()) + { + if (async) + await s.CopyToAsync(ms); + else + s.CopyTo(ms); + + Assert.Equal(content, ms.ToArray()); + } + } + } + [Fact] public void Negative_WrongPassword_Throws_InvalidDataException() { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index dad607aff1791a..f723e5515131b9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -12,6 +12,7 @@ namespace System.IO.Compression internal sealed class WinZipAesStream : Stream { private const int BLOCK_SIZE = 16; // AES block size in bytes + private const int KEYSTREAM_BUFFER_SIZE = 4096; // Pre-generate 4KB of keystream (256 blocks) private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -37,6 +38,10 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; private int _partialBlockBytes; + // Pre-generated keystream buffer for efficiency + private readonly byte[] _keystreamBuffer = new byte[KEYSTREAM_BUFFER_SIZE]; + private int _keystreamOffset = KEYSTREAM_BUFFER_SIZE; // Start depleted to force initial generation + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -259,49 +264,77 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); int processed = 0; - byte[] keystream = new byte[16]; while (processed < count) { - _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - - // Log keystream for first block - if (processed == 0) + // Ensure we have enough keystream bytes available + int keystreamAvailable = KEYSTREAM_BUFFER_SIZE - _keystreamOffset; + if (keystreamAvailable == 0) { - Debug.WriteLine($"First keystream block: {BitConverter.ToString(keystream)}"); + GenerateKeystreamBuffer(); + keystreamAvailable = KEYSTREAM_BUFFER_SIZE; } - IncrementCounter(); - - int blockSize = Math.Min(16, count - processed); + // Process as many bytes as possible with the available keystream + int bytesToProcess = Math.Min(count - processed, keystreamAvailable); // For encryption: XOR first, then HMAC the ciphertext if (_encrypting) { // XOR the data with the keystream to create ciphertext - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } + XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); // HMAC is computed on the ciphertext (after XOR) - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); } // For decryption: HMAC first (on ciphertext), then XOR else { // HMAC is computed on the ciphertext (before XOR) - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); // XOR the ciphertext with the keystream to recover plaintext - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } + XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); } - processed += blockSize; + _keystreamOffset += bytesToProcess; + processed += bytesToProcess; + } + } + + private void GenerateKeystreamBuffer() + { + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); + + // Generate KEYSTREAM_BUFFER_SIZE bytes of keystream (256 blocks of 16 bytes each) + for (int i = 0; i < KEYSTREAM_BUFFER_SIZE; i += BLOCK_SIZE) + { + _aesEncryptor.TransformBlock(_counterBlock, 0, BLOCK_SIZE, _keystreamBuffer, i); + IncrementCounter(); + } + + _keystreamOffset = 0; + } + + private static void XorBytes(byte[] dest, int destOffset, byte[] src, int srcOffset, int count) + { + Span destSpan = dest.AsSpan(destOffset, count); + ReadOnlySpan srcSpan = src.AsSpan(srcOffset, count); + + // Process 8 bytes at a time when possible for better performance + int i = 0; + while (i + 8 <= count) + { + long destVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(destSpan.Slice(i)); + long srcVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(srcSpan.Slice(i)); + System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(destSpan.Slice(i), destVal ^ srcVal); + i += 8; } - Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); + // Handle remaining bytes + while (i < count) + { + destSpan[i] ^= srcSpan[i]; + i++; + } } private void IncrementCounter() From e2b21ff0734cd32360722ac07688159bb8869250 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 2 Dec 2025 15:52:26 +0100 Subject: [PATCH 21/83] update tests, fixed async opening and updated stream positioning --- .../tests/ZipFile.Encryption.cs | 1 - .../System/IO/Compression/WinZipAesStream.cs | 29 +------ .../IO/Compression/ZipArchiveEntry.Async.cs | 78 +++++++++++++++++-- .../System/IO/Compression/ZipArchiveEntry.cs | 11 +-- .../System/IO/Compression/ZipBlocks.Async.cs | 64 +++++++++++++++ .../System/IO/Compression/ZipCryptoStream.cs | 10 +-- 6 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index dad9cd96be8ead..230abc3312a508 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -287,7 +287,6 @@ public void Negative_OpeningPlainEntryWithPassword_Throws() [Theory] [MemberData(nameof(Get_Is_Async))] - // todo: some async methods in ziparchiveentry missing implementation for winzipaesstream public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index f723e5515131b9..03851f02408820 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -28,7 +28,6 @@ internal sealed class WinZipAesStream : Stream private byte[]? _passwordVerifier; private bool _headerWritten; private bool _headerRead; - private long _position; private bool _disposed; private bool _authCodeValidated; private readonly long _totalStreamSize; @@ -445,8 +444,6 @@ private async Task ReadCoreShared(Memory buffer, bool isAsync, Cancel byte[] temp = buffer.Slice(0, bytesRead).ToArray(); ProcessBlock(temp, 0, bytesRead); temp.CopyTo(buffer.Span); - - _position += bytesRead; } else // n == 0, meaning end of stream { @@ -535,7 +532,6 @@ private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, Ca else _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - _position += BLOCK_SIZE; _partialBlockBytes = 0; } } @@ -564,7 +560,6 @@ private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, Ca else _baseStream.Write(chunkBuffer, 0, bytesToProcess); - _position += bytesToProcess; inputOffset += bytesToProcess; inputCount -= bytesToProcess; } @@ -625,7 +620,6 @@ private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cance _baseStream.Write(_partialBlock, 0, _partialBlockBytes); } - _position += _partialBlockBytes; _partialBlockBytes = 0; } } @@ -709,28 +703,7 @@ public override async ValueTask DisposeAsync() public override long Length => throw new NotSupportedException(); public override long Position { - get - { - // Calculate the actual position including all metadata - long position = _position; - - // Add header size if it has been written/read - if (_headerWritten || _headerRead) - { - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - position += headerSize; - } - - // Add auth code size if it has been written/validated - if (_authCodeValidated) - { - const int authCodeSize = 10; - position += authCodeSize; - } - - return position; - } + get => throw new NotSupportedException(); set => throw new NotSupportedException(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 876d9463da6240..364acdd9cd53eb 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -61,12 +61,30 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance cancellationToken.ThrowIfCancellationRequested(); if (_storedOffsetOfCompressedData == null) { + // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - // by calling this, we are using local header _storedEntryNameBytes.Length and extraFieldLength - // to find start of data, but still using central directory size information - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; + + long baseOffset; + + if (!IsEncrypted || IsZipCryptoEncrypted()) + { + // Non-AES case: just skip the local header + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + else + { + // AES case + var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + if (!success) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + + _storedOffsetOfCompressedData = baseOffset; } return _storedOffsetOfCompressedData.Value; } @@ -84,6 +102,19 @@ private async Task GetUncompressedDataAsync(CancellationToken canc if (_originallyInArchive) { + if (_isEncrypted) + { + // We don't support edit-in-place for encrypted entries without an explicit password flow. + // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Editing an encrypted entry in-place is not supported. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); + } + Stream decompressor = await OpenInReadModeAsync(false, cancellationToken).ConfigureAwait(false); await using (decompressor) { @@ -280,11 +311,44 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel { return (false, message); } - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + + if (!IsEncrypted && !await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) { message = SR.LocalFileHeaderCorrupt; return (false, message); } + else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); + _aesCompressionMethod = CompressionMethodValues.Aes; + var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + if (!success) + { + message = SR.LocalFileHeaderCorrupt; + return (false, message); + } + + if (aesExtraField.HasValue) + { + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + + _aeVersion = aesExtraField.Value.VendorVersion; + + // Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly + // Set the compression method to the actual method for decompression + CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + } + } // when this property gets called, some duplicated work long offsetOfCompressedData = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); @@ -354,7 +418,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can //The compressor fills in CRC and sizes //The DirectToArchiveWriterStream writes headers and such - DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null), this); + DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); await using (entryWriter) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); 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 eaa52a32bd33c5..d4fb02457036e5 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 @@ -474,7 +474,7 @@ private MemoryStream GetUncompressedData(string? password = null) if (_isEncrypted) { - // We dont support edit-in-place for encrypted entries without an explicit password flow. + // We don’t support edit-in-place for encrypted entries without an explicit password flow. // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. _storedUncompressedData.Dispose(); _storedUncompressedData = null; @@ -813,7 +813,7 @@ private void DetectEntryNameVersion() } - private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, Stream? streamForPosition = null) { // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream @@ -851,7 +851,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; var checkSumStream = new CheckSumAndSizeWriteStream( compressorStreamFactory, - backingStream, + streamForPosition ?? backingStream, leaveCompressorStreamOpenOnClose, this, onClose, @@ -1039,7 +1039,8 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod var entry = (ZipArchiveEntry)o!; entry._archive.ReleaseArchiveStream(entry); entry._outstandingWriteStream = null; - }); + }, + encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); @@ -1453,7 +1454,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) //The compressor fills in CRC and sizes //The DirectToArchiveWriterStream writes headers and such using (DirectToArchiveWriterStream entryWriter = new( - GetDataCompressor(_archive.ArchiveStream, true, null), + GetDataCompressor(_archive.ArchiveStream, true, null, null), this)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); 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 8305b5e42bfdce..d5d8b8c232ebb5 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 @@ -156,6 +156,70 @@ 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); } + + public static async Task<(bool success, WinZipAesExtraField? aesExtraField)> TrySkipBlockAESAwareAsync(Stream stream, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + WinZipAesExtraField? aesExtraField = null; + + // Read the first 4 bytes (local file header signature) + byte[] signatureBytes = new byte[4]; + await stream.ReadExactlyAsync(signatureBytes, cancellationToken).ConfigureAwait(false); + if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) + { + return (false, null); // Not a valid local file header + } + + // Read fixed-size fields after signature + // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) + byte[] skipBuffer = new byte[22]; + await stream.ReadExactlyAsync(skipBuffer, cancellationToken).ConfigureAwait(false); + + byte[] lengthBuffer = new byte[4]; + await stream.ReadExactlyAsync(lengthBuffer, cancellationToken).ConfigureAwait(false); + ushort nameLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(0, 2)); + ushort extraLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(2, 2)); + + // Skip file name + stream.Seek(nameLength, SeekOrigin.Current); + + // Parse extra fields if present + if (extraLength > 0) + { + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; + + byte[] fieldHeader = new byte[4]; + while (stream.Position < extraEnd) + { + await stream.ReadExactlyAsync(fieldHeader, cancellationToken).ConfigureAwait(false); + ushort headerId = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(0, 2)); + ushort dataSize = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(2, 2)); + + if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + byte[] aesData = new byte[7]; + await stream.ReadExactlyAsync(aesData, cancellationToken).ConfigureAwait(false); + ushort vendorVersion = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(0, 2)); + // Skip vendor ID 'AE' (bytes 2-3) + byte aesStrength = aesData[4]; // 1, 2, or 3 + ushort compressionMethod = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(5, 2)); + + aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); + break; + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } + } + } + + return (true, aesExtraField); + } } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 8cd53d2b08fe6f..bdda034f6c9c8a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -16,7 +16,6 @@ internal sealed class ZipCryptoStream : Stream private bool _headerWritten; private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) - private int _position; private uint _key0; private uint _key1; @@ -43,7 +42,6 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex InitKeysFromBytes(password.Span); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes - _position = 0; } // Encryption constructor @@ -58,7 +56,6 @@ public ZipCryptoStream(Stream baseStream, _leaveOpen = leaveOpen; _verifierLow2Bytes = passwordVerifierLow2Bytes; _crc32ForHeader = crc32; - _position = 0; InitKeysFromBytes(password.Span); } @@ -109,7 +106,6 @@ private async ValueTask WriteHeaderCore(bool isAsync, CancellationToken cancella } _headerWritten = true; - _position += 12; } private void EnsureHeader() @@ -181,7 +177,7 @@ private byte DecryptByte(byte ciph) public override long Length => throw new NotSupportedException(); public override long Position { - get => _position; + get => throw new NotSupportedException(); set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); } public override void Flush() => _base.Flush(); @@ -199,7 +195,6 @@ public override int Read(Span destination) int n = _base.Read(destination); for (int i = 0; i < n; i++) destination[i] = DecryptByte(destination[i]); - _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -228,7 +223,6 @@ public override void Write(ReadOnlySpan buffer) UpdateKeys(p); } _base.Write(tmp, 0, tmp.Length); - _position += tmp.Length; } protected override void Dispose(bool disposing) @@ -271,7 +265,6 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation Span span = buffer.Span; for (int i = 0; i < n; i++) span[i] = DecryptByte(span[i]); - _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -302,7 +295,6 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella } await _base.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += tmp.Length; } public override Task FlushAsync(CancellationToken cancellationToken) From 3a0e9630dc7a4e453ef3886fc6705e7e39d4eb59 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 12 Dec 2025 15:24:01 +0200 Subject: [PATCH 22/83] fix comments, proper conflict resolution and break circular dependency --- .../Win32/SafeHandles/SafeX509Handles.Unix.cs | 6 +- .../ZipFileExtensions.ZipArchive.Create.cs | 1 + .../tests/ZipFile.Encryption.cs | 62 +-- .../ref/System.IO.Compression.cs | 1 + .../src/System.IO.Compression.csproj | 4 +- .../System/IO/Compression/WinZipAesStream.cs | 524 +++++++++--------- .../IO/Compression/ZipArchiveEntry.Async.cs | 8 +- .../System/IO/Compression/ZipArchiveEntry.cs | 60 +- .../IO/Compression/ZipCompressionMethod.cs | 5 + .../System/IO/Compression/ZipCustomStreams.cs | 112 +--- .../src/System.Security.Cryptography.csproj | 2 +- 11 files changed, 363 insertions(+), 422 deletions(-) diff --git a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs index 7017405cb4a6a0..45b426661eeaa9 100644 --- a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs +++ b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs @@ -13,8 +13,10 @@ internal sealed class SafeX509Handle : SafeHandle private static readonly bool s_captureTrace = Environment.GetEnvironmentVariable("DEBUG_SAFEX509HANDLE_FINALIZATION") != null; - private readonly StackTrace? _stacktrace = - s_captureTrace ? new StackTrace(fNeedFileInfo: true) : null; + // Using reflection to avoid a hard dependency on System.Diagnostics.StackTrace, which prevents + // System.IO.Compression from referencing this assembly. + private readonly object? _stacktrace = + s_captureTrace ? Activator.CreateInstance(Type.GetType("System.Diagnostics.StackTrace")!, true) : null; ~SafeX509Handle() { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index f9a79d83be7df7..90c28c1fedda01 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -102,6 +102,7 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); + // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 230abc3312a508..50af49563fb800 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -13,7 +13,7 @@ namespace System.IO.Compression.Tests { public class ZipFile_EncryptionTests : ZipFileTestBase { - public static IEnumerable Get_SingleEntry_Data() + public static IEnumerable EncryptionMethodAndBoolTestData() { foreach (var method in new[] { @@ -28,54 +28,8 @@ public static IEnumerable Get_SingleEntry_Data() } } - public static IEnumerable Get_MultipleEntries_SamePassword_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_MultipleEntries_DifferentPasswords_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_MixedPlainEncrypted_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes128, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_Is_Async() - { - yield return new object[] { false }; - yield return new object[] { true }; - } - [Theory] - [MemberData(nameof(Get_SingleEntry_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -97,7 +51,7 @@ public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMet } [Theory] - [MemberData(nameof(Get_MultipleEntries_SamePassword_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -122,7 +76,7 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEn } [Theory] - [MemberData(nameof(Get_MultipleEntries_DifferentPasswords_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -146,7 +100,7 @@ public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArc } [Theory] - [MemberData(nameof(Get_MixedPlainEncrypted_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -173,7 +127,7 @@ public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.En } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task Encryption_Combinations_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -198,7 +152,7 @@ public async Task Encryption_Combinations_RoundTrip(bool async) } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task Encryption_LargeFile_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -286,7 +240,7 @@ public void Negative_OpeningPlainEntryWithPassword_Throws() } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); 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 fe8cc2c10dd532..03eda5c7a279da 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -151,6 +151,7 @@ public enum ZipCompressionMethod Stored = 0, Deflate = 8, Deflate64 = 9, + Aes = 99 } public sealed partial class ZLibCompressionOptions { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 6715dc201493bc..994062f96a0a40 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -51,6 +51,7 @@ + @@ -80,6 +81,7 @@ + - + \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 03851f02408820..016eeddc2d1edf 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -11,8 +12,8 @@ namespace System.IO.Compression { internal sealed class WinZipAesStream : Stream { - private const int BLOCK_SIZE = 16; // AES block size in bytes - private const int KEYSTREAM_BUFFER_SIZE = 4096; // Pre-generate 4KB of keystream (256 blocks) + private const int BlockSize = 16; // AES block size in bytes + private const int KeystreamBufferSize = 4096; // Pre-generate 4KB of keystream (256 blocks) private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -21,7 +22,7 @@ internal sealed class WinZipAesStream : Stream #pragma warning disable CA1416 // HMACSHA1 is available on all platforms private readonly HMACSHA1 _hmac; #pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[BLOCK_SIZE]; + private readonly byte[] _counterBlock = new byte[BlockSize]; private byte[]? _key; private byte[]? _hmacKey; private byte[]? _salt; @@ -34,12 +35,12 @@ internal sealed class WinZipAesStream : Stream private readonly bool _leaveOpen; private readonly long _encryptedDataSize; private long _encryptedDataRemaining; - private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; + private readonly byte[] _partialBlock = new byte[BlockSize]; private int _partialBlockBytes; // Pre-generated keystream buffer for efficiency - private readonly byte[] _keystreamBuffer = new byte[KEYSTREAM_BUFFER_SIZE]; - private int _keystreamOffset = KEYSTREAM_BUFFER_SIZE; // Start depleted to force initial generation + private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; + private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { @@ -95,89 +96,77 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en } else { + // For decryption, we must know the total size to locate the auth tag + if (_totalStreamSize <= 0) + { + throw new ArgumentException("Total stream size must be provided for decryption.", nameof(totalStreamSize)); + } + + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; + const int hmacSize = 10; + + _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + _encryptedDataRemaining = _encryptedDataSize; + + if (_encryptedDataSize < 0) + { + throw new InvalidDataException("Stream size is too small for WinZip AES format."); + } + ReadHeader(password); } } private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password.ToArray()); + // Calculate sizes + int keySizeInBytes = _keySizeBits / 8; + int totalKeySize = keySizeInBytes + keySizeInBytes + 2; + + // Handle password encoding without heap allocation + // We use a buffer on the stack for the UTF8 bytes + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + Span passwordBytes = stackalloc byte[maxPasswordByteCount]; + int actualByteCount = Encoding.UTF8.GetBytes(password.Span, passwordBytes); + Span passwordSpan = passwordBytes[..actualByteCount]; + + // Allocate the derived key buffer on the stack + Span derivedKey = stackalloc byte[totalKeySize]; try { - // AES key size + HMAC key size (same as AES key) + password verifier (2 bytes) - int keySizeInBytes = _keySizeBits / 8; - int totalKeySize = keySizeInBytes + keySizeInBytes + 2; - - // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( - passwordBytes, - salt, - 1000, - HashAlgorithmName.SHA1, - totalKeySize); + // Use the Span-based PBKDF2 overload (No heap allocation for result) + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + salt, + derivedKey, + 1000, + HashAlgorithmName.SHA1); - // Split the derived key material + // Initialize class-level arrays _key = new byte[keySizeInBytes]; _hmacKey = new byte[keySizeInBytes]; _passwordVerifier = new byte[2]; - // First: AES encryption key - derivedKey.AsSpan(0, _key.Length).CopyTo(_key); - // Second: HMAC authentication key (same size as encryption key) - derivedKey.AsSpan(_key.Length, _hmacKey.Length).CopyTo(_hmacKey); - // Third: Password verification value (2 bytes) - derivedKey.AsSpan(_key.Length + _hmacKey.Length).CopyTo(_passwordVerifier); - // Clear the derived key from memory - Array.Clear(derivedKey, 0, derivedKey.Length); + // Copy derived material into destination buffers + derivedKey.Slice(0, keySizeInBytes).CopyTo(_key); + derivedKey.Slice(keySizeInBytes, keySizeInBytes).CopyTo(_hmacKey); + derivedKey.Slice(keySizeInBytes * 2, 2).CopyTo(_passwordVerifier); } finally { - // Clear the password bytes from memory - Array.Clear(passwordBytes, 0, passwordBytes.Length); + // Clear the stack memory + CryptographicOperations.ZeroMemory(passwordBytes); + CryptographicOperations.ZeroMemory(derivedKey); } } - private void ReadHeader(ReadOnlyMemory password) - { - if (_headerRead) return; - - // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - // Debug: Log the salt - Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - - // Read the 2-byte password verifier - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - // Derive keys from password and salt - DeriveKeysFromPassword(password, _salt); - - // Verify the password - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - { - throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); - } - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - Array.Clear(_counterBlock, 0, 16); - _counterBlock[0] = 1; - - _headerRead = true; - } - private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { - if (_encrypting || _authCodeValidated) + Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + + if (_authCodeValidated) return; // Finalize HMAC computation @@ -216,6 +205,54 @@ private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); } + private async Task ValidateAuthCodeIfNeededAsync(bool isAsync, CancellationToken cancellationToken) + { + if (!_authCodeValidated) + { + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); + } + } + + private void ReadHeader(ReadOnlyMemory password) + { + if (_headerRead) return; + + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); + + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Derive keys from password and salt + DeriveKeysFromPassword(password, _salt); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password"); + } + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } + private void InitCipher() { Debug.Assert(_key is not null, "_key is not null"); @@ -267,21 +304,24 @@ private void ProcessBlock(byte[] buffer, int offset, int count) while (processed < count) { // Ensure we have enough keystream bytes available - int keystreamAvailable = KEYSTREAM_BUFFER_SIZE - _keystreamOffset; + int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; if (keystreamAvailable == 0) { GenerateKeystreamBuffer(); - keystreamAvailable = KEYSTREAM_BUFFER_SIZE; + keystreamAvailable = KeystreamBufferSize; } // Process as many bytes as possible with the available keystream int bytesToProcess = Math.Min(count - processed, keystreamAvailable); + Span dataSpan = buffer.AsSpan(offset + processed, bytesToProcess); + ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); + // For encryption: XOR first, then HMAC the ciphertext if (_encrypting) { // XOR the data with the keystream to create ciphertext - XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); + XorBytes(dataSpan, keystreamSpan); // HMAC is computed on the ciphertext (after XOR) _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); } @@ -291,7 +331,7 @@ private void ProcessBlock(byte[] buffer, int offset, int count) // HMAC is computed on the ciphertext (before XOR) _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); // XOR the ciphertext with the keystream to recover plaintext - XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); + XorBytes(dataSpan, keystreamSpan); } _keystreamOffset += bytesToProcess; @@ -303,36 +343,23 @@ private void GenerateKeystreamBuffer() { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); - // Generate KEYSTREAM_BUFFER_SIZE bytes of keystream (256 blocks of 16 bytes each) - for (int i = 0; i < KEYSTREAM_BUFFER_SIZE; i += BLOCK_SIZE) + // Generate KeystreamBufferSize bytes of keystream (256 blocks of 16 bytes each) + for (int i = 0; i < KeystreamBufferSize; i += BlockSize) { - _aesEncryptor.TransformBlock(_counterBlock, 0, BLOCK_SIZE, _keystreamBuffer, i); + _aesEncryptor.TransformBlock(_counterBlock, 0, BlockSize, _keystreamBuffer, i); IncrementCounter(); } _keystreamOffset = 0; } - private static void XorBytes(byte[] dest, int destOffset, byte[] src, int srcOffset, int count) + private static void XorBytes(Span dest, ReadOnlySpan src) { - Span destSpan = dest.AsSpan(destOffset, count); - ReadOnlySpan srcSpan = src.AsSpan(srcOffset, count); + Debug.Assert(dest.Length <= src.Length); - // Process 8 bytes at a time when possible for better performance - int i = 0; - while (i + 8 <= count) + for (int i = 0; i < dest.Length; i++) { - long destVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(destSpan.Slice(i)); - long srcVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(srcSpan.Slice(i)); - System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(destSpan.Slice(i), destVal ^ srcVal); - i += 8; - } - - // Handle remaining bytes - while (i < count) - { - destSpan[i] ^= srcSpan[i]; - i++; + dest[i] ^= src[i]; } } @@ -348,7 +375,9 @@ private void IncrementCounter() private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { - if (!_encrypting || _authCodeValidated) + Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); + + if ( _authCodeValidated) return; _hmac.TransformFinalBlock(Array.Empty(), 0, 0); @@ -372,17 +401,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel _authCodeValidated = true; } - private void WriteAuthCode() - { - WriteAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - } - - private Task WriteAuthCodeAsync(CancellationToken cancellationToken) - { - return WriteAuthCodeCoreAsync(isAsync: true, cancellationToken); - } - - private async Task ReadCoreShared(Memory buffer, bool isAsync, CancellationToken cancellationToken) + private void ThrowIfNotReadable() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -391,215 +410,236 @@ private async Task ReadCoreShared(Memory buffer, bool isAsync, Cancel if (!_headerRead) throw new InvalidOperationException("Header must be read before reading data."); + } - int bytesToRead = buffer.Length; + private int GetBytesToRead(int requestedCount) + { + if (_encryptedDataRemaining <= 0) + return 0; - // If we know the total size, ensure we don't read into the HMAC - if (_encryptedDataSize > 0) - { - if (_encryptedDataRemaining <= 0) - { - if (!_authCodeValidated) - { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); - } - return 0; - } + return (int)Math.Min(requestedCount, _encryptedDataRemaining); + } - bytesToRead = (int)Math.Min(bytesToRead, _encryptedDataRemaining); - } + 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) + { + ThrowIfNotReadable(); + int bytesToRead = GetBytesToRead(buffer.Length); if (bytesToRead == 0) { - if (!_authCodeValidated && _encryptedDataSize > 0) - { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); - } + ValidateAuthCode(); return 0; } - int bytesRead; - if (isAsync) - { - bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = _baseStream.Read(buffer.Span.Slice(0, bytesToRead)); - } - - Debug.WriteLine($"Read {bytesRead} bytes from base stream"); + // We need a byte[] for ProcessBlock due to HMAC.TransformBlock requirement + byte[] tempArray = new byte[bytesToRead]; + int bytesRead = _baseStream.Read(tempArray, 0, bytesToRead); if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; + ProcessBlock(tempArray, 0, bytesRead); + tempArray.AsSpan(0, bytesRead).CopyTo(buffer); - // Process the data - we need to copy because ProcessBlock modifies in-place - byte[] temp = buffer.Slice(0, bytesRead).ToArray(); - ProcessBlock(temp, 0, bytesRead); - temp.CopyTo(buffer.Span); - } - else // n == 0, meaning end of stream - { - if (!_authCodeValidated) + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); + ValidateAuthCode(); } } + else + { + ValidateAuthCode(); + } return bytesRead; } - private int ReadCore(Span buffer) - { - // Convert span to memory and call shared method synchronously - byte[] tempArray = new byte[buffer.Length]; - Memory memoryBuffer = tempArray.AsMemory(); - - int bytesRead = ReadCoreShared(memoryBuffer, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - - // Copy the processed data back to the original span - memoryBuffer.Span.Slice(0, bytesRead).CopyTo(buffer); - - return bytesRead; - } - - public override int Read(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - return ReadCore(new Span(buffer, offset, count)); - } - - public override int Read(Span buffer) - { - return ReadCore(buffer); - } - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - return await ReadCoreShared(new Memory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - return await ReadCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); - } + ThrowIfNotReadable(); - private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); + int bytesToRead = GetBytesToRead(buffer.Length); + if (bytesToRead == 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + return 0; + } - // Write header if needed - if (!_headerWritten) + int bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) { - if (isAsync) - await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + _encryptedDataRemaining -= bytesRead; + + // Try to process in-place if Memory is array-backed + if (MemoryMarshal.TryGetArray(buffer.Slice(0, bytesRead), out ArraySegment segment)) + { + ProcessBlock(segment.Array!, segment.Offset, bytesRead); + } else - WriteHeader(); + { + // Fallback for non-array-backed Memory (rare) + byte[] temp = buffer.Slice(0, bytesRead).ToArray(); + ProcessBlock(temp, 0, bytesRead); + temp.CopyTo(buffer.Span); + } + + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } } + else + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } + + return bytesRead; + } + private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) + { int inputOffset = 0; int inputCount = buffer.Length; // Fill the partial block buffer if it has data if (_partialBlockBytes > 0) { - int copyCount = Math.Min(BLOCK_SIZE - _partialBlockBytes, inputCount); - buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); _partialBlockBytes += copyCount; inputOffset += copyCount; inputCount -= copyCount; // If full, encrypt and write immediately - if (_partialBlockBytes == BLOCK_SIZE) + if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BLOCK_SIZE); - - if (isAsync) - await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BLOCK_SIZE), cancellationToken).ConfigureAwait(false); - else - _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - + ProcessBlock(_partialBlock, 0, BlockSize); + _baseStream.Write(_partialBlock, 0, BlockSize); _partialBlockBytes = 0; } } - // Process full blocks directly from the input - if (inputCount >= BLOCK_SIZE) + // Process full blocks + while (inputCount >= BlockSize) { - const int ChunkSize = 4096; - byte[] chunkBuffer = new byte[ChunkSize]; + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; - while (inputCount >= BLOCK_SIZE) - { - // Round down to nearest multiple of 16 for the chunk - int bytesToProcess = Math.Min(inputCount, ChunkSize); - bytesToProcess = (bytesToProcess / BLOCK_SIZE) * BLOCK_SIZE; - - // Copy input to local buffer - buffer.Slice(inputOffset, bytesToProcess).CopyTo(chunkBuffer); - - // Encrypt in-place - ProcessBlock(chunkBuffer, 0, bytesToProcess); - - // Write to stream - if (isAsync) - await _baseStream.WriteAsync(chunkBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); - else - _baseStream.Write(chunkBuffer, 0, bytesToProcess); + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer, 0, bytesToProcess); + _baseStream.Write(workBuffer, 0, bytesToProcess); - inputOffset += bytesToProcess; - inputCount -= bytesToProcess; - } + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; } // Buffer any remaining bytes if (inputCount > 0) { - buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); _partialBlockBytes += inputCount; } } - private void WriteCore(ReadOnlySpan buffer) + private void ThrowIfNotWritable() { - // Convert span to memory and call shared method synchronously - byte[] tempArray = buffer.ToArray(); - WriteCoreShared(tempArray, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); } public override void Write(byte[] buffer, int offset, int count) { ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); + Write(buffer.AsSpan(offset, count)); } public override void Write(ReadOnlySpan buffer) { - WriteCore(buffer); + ThrowIfNotWritable(); + if (!_headerWritten) + { + WriteHeader(); + } + + byte[] workBuffer = new byte[KeystreamBufferSize]; + WriteCore(buffer, workBuffer); } - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - await WriteCoreShared(new ReadOnlyMemory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - await WriteCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + ThrowIfNotWritable(); + if (!_headerWritten) + { + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + } + + int inputOffset = 0; + int inputCount = buffer.Length; + byte[] workBuffer = new byte[KeystreamBufferSize]; + + // Fill the partial block buffer if it has data + if (_partialBlockBytes > 0) + { + int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= copyCount; + + // If full, encrypt and write immediately + if (_partialBlockBytes == BlockSize) + { + ProcessBlock(_partialBlock, 0, BlockSize); + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BlockSize), cancellationToken).ConfigureAwait(false); + _partialBlockBytes = 0; + } + } + + // Process full blocks + while (inputCount >= BlockSize) + { + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; + + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer, 0, bytesToProcess); + await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); + + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } + + // Buffer any remaining bytes + if (inputCount > 0) + { + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + _partialBlockBytes += inputCount; + } } @@ -634,18 +674,14 @@ protected override void Dispose(bool disposing) { if (_encrypting && !_authCodeValidated && _headerWritten) { - // 1. Encrypt remaining partial data + // Encrypt remaining partial data FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - // 2. Write Auth Code - WriteAuthCode(); + // Write Auth Code + WriteAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); if (_baseStream.CanWrite) _baseStream.Flush(); } - else if (!_encrypting && !_authCodeValidated && _headerRead) - { - ValidateAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - } } finally { @@ -669,20 +705,14 @@ public override async ValueTask DisposeAsync() { if (_encrypting && !_authCodeValidated && _headerWritten) { - await _baseStream.FlushAsync().ConfigureAwait(false); - - // 1. Encrypt remaining partial data + // Encrypt remaining partial data await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); - // 2. Write Auth Code - await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + // Write Auth Code + await WriteAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); if (_baseStream.CanWrite) await _baseStream.FlushAsync().ConfigureAwait(false); } - else if (!_encrypting && !_authCodeValidated && _headerRead) - { - await ValidateAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); - } } finally { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index dc8514f598514a..97ab25e7d56755 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -76,7 +76,7 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance } else { - // AES case + // AES case - need to parse the AES extra field to find the actual compression method and skip the correct number of bytes var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -317,10 +317,10 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = CompressionMethodValues.Aes; + _aesCompressionMethod = ZipCompressionMethod.Aes; var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { @@ -346,7 +346,7 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; } } 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 7f4dc99e8c66e6..a9fb101c16807a 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 @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private CompressionMethodValues _aesCompressionMethod; + private ZipCompressionMethod _aesCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -99,7 +99,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _fileComment = cd.FileComment; _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); - } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -163,7 +162,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) } Changes = ZipArchive.ChangeState.Unchanged; - } /// @@ -379,6 +377,7 @@ public Stream Open() return OpenInWriteMode(); case ZipArchiveMode.Update: default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); return OpenInUpdateMode(); } } @@ -461,7 +460,7 @@ internal long GetOffsetOfCompressedData() } else { - // AES case + // AES case - need to also parse the AES extra field and skip the correct number of bytes if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -636,12 +635,12 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 // determine if we can fit zip64 extra field and original extra fields all in int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) - + aesExtraFieldSize // Add this line + + aesExtraFieldSize + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); // Modified line + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _cdUnknownExtraFields = null; } else @@ -837,7 +836,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool || CompressionMethod == ZipCompressionMethod.Stored); Func compressorStreamFactory; - bool isIntermediateStream = true; switch (CompressionMethod) @@ -882,14 +880,13 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - const ushort EncryptionFlag = 0x0001; - return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0 && !IsAesEncrypted(); + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && !IsAesEncrypted(); } private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _aesCompressionMethod == CompressionMethodValues.Aes; + return _aesCompressionMethod == ZipCompressionMethod.Aes; } private bool ForAesEncryption() @@ -908,13 +905,13 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) case ZipCompressionMethod.Deflate64: uncompressedStream = new DeflateManagedStream(compressedStreamToRead, ZipCompressionMethod.Deflate64, _uncompressedSize); break; - case CompressionMethodValues.Stored: + case ZipCompressionMethod.Stored: uncompressedStream = compressedStreamToRead; break; default: // We should not get here with Aes as CompressionMethod anymore // as it should have been replaced with the actual compression method - Debug.Assert(CompressionMethod != CompressionMethodValues.Aes, + Debug.Assert(CompressionMethod != ZipCompressionMethod.Aes, "AES compression method should have been replaced with actual compression method"); // Fallback to stored if we somehow get here @@ -1090,10 +1087,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = CompressionMethodValues.Aes; + _aesCompressionMethod = ZipCompressionMethod.Aes; + // AES case - need to read the extra field to determine actual compression method and encryption strength if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { message = SR.LocalFileHeaderCorrupt; @@ -1102,24 +1100,24 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st if (aesExtraField.HasValue) { + + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch { - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - } + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + _aeVersion = aesExtraField.Value.VendorVersion; // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; } } @@ -1143,16 +1141,16 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m if (needToUncompress) { if (!IsEncrypted && - CompressionMethod != CompressionMethodValues.Stored && - CompressionMethod != CompressionMethodValues.Deflate && - CompressionMethod != CompressionMethodValues.Deflate64) + CompressionMethod != ZipCompressionMethod.Stored && + CompressionMethod != ZipCompressionMethod.Deflate && + CompressionMethod != ZipCompressionMethod.Deflate64) { message = SR.UnsupportedCompression; return false; } else { - if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { return true; } @@ -1289,7 +1287,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; // Set compression method to 99 (AES indicator) in the header - CompressionMethod = CompressionMethodValues.Aes; + CompressionMethod = ZipCompressionMethod.Aes; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; aesExtraField = new WinZipAesExtraField diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs index 7ca56e277bbd2f..4ad210316bd4f0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs @@ -25,5 +25,10 @@ public enum ZipCompressionMethod /// The entry is compressed using the Deflate64 algorithm. /// Deflate64 = 0x9, + + /// + /// The entry is encrypted using AES standard. + /// + Aes = 99 } } 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 43aa748169842d..4597cdd4f0a712 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 @@ -702,6 +702,7 @@ internal sealed class CrcValidatingReadStream : Stream private long _totalBytesRead; private readonly long _expectedLength; private bool _isDisposed; + private bool _crcValidated; public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) { @@ -710,6 +711,7 @@ public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expecte _expectedLength = expectedLength; _runningCrc = 0; _totalBytesRead = 0; + _crcValidated = false; } public override bool CanRead => !_isDisposed && _baseStream.CanRead; @@ -730,17 +732,7 @@ public override int Read(byte[] buffer, int offset, int count) ValidateBufferArguments(buffer, offset, count); int bytesRead = _baseStream.Read(buffer, offset, count); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); return bytesRead; } @@ -750,17 +742,7 @@ public override int Read(Span buffer) ThrowIfDisposed(); int bytesRead = _baseStream.Read(buffer); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Slice(0, bytesRead)); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.Slice(0, bytesRead)); return bytesRead; } @@ -771,17 +753,7 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, ValidateBufferArguments(buffer, offset, count); int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); return bytesRead; } @@ -791,40 +763,40 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation ThrowIfDisposed(); int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Span.Slice(0, bytesRead)); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.Span.Slice(0, bytesRead)); return bytesRead; } - public override void Write(byte[] buffer, int offset, int count) + private void ProcessBytesRead(ReadOnlySpan data) { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); - } + if (data.Length > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, data); + _totalBytesRead += data.Length; - public override void Write(ReadOnlySpan buffer) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); + if (_totalBytesRead >= _expectedLength) + { + ValidateCrc(); + } + } } - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + private void ValidateCrc() { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); + if (_crcValidated) + return; + + _crcValidated = true; + + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Actual: 0x{_runningCrc:X8}"); + } } - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + public override void Write(byte[] buffer, int offset, int count) { ThrowIfDisposed(); throw new NotSupportedException(SR.WritingNotSupported); @@ -833,13 +805,12 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo public override void Flush() { ThrowIfDisposed(); - _baseStream.Flush(); } public override Task FlushAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); - return _baseStream.FlushAsync(cancellationToken); + return Task.CompletedTask; } public override long Seek(long offset, SeekOrigin origin) @@ -854,31 +825,15 @@ public override void SetLength(long value) throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); } - private void ValidateCrc() - { - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); - } - } - private void ThrowIfDisposed() { - if (_isDisposed) - throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName); + ObjectDisposedException.ThrowIf(_isDisposed, this); } protected override void Dispose(bool disposing) { if (disposing && !_isDisposed) { - // Validate CRC when stream is closed (if all data was read) - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException("CRC mismatch"); - } - _baseStream.Dispose(); _isDisposed = true; } @@ -889,13 +844,6 @@ public override async ValueTask DisposeAsync() { if (!_isDisposed) { - // Validate CRC when stream is closed (if all data was read) - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); - } - await _baseStream.DisposeAsync().ConfigureAwait(false); _isDisposed = true; } diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index b2f8508b14ef39..fae2ff40c02c0e 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent) From 703a6ec4e0c79df57e95344e2b55ff89f423ffd5 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 15 Dec 2025 13:15:29 +0200 Subject: [PATCH 23/83] replace error strings --- .../src/Resources/Strings.resx | 27 +++++++++++++++++++ .../System/IO/Compression/WinZipAesStream.cs | 21 +++++++-------- .../System/IO/Compression/ZipArchiveEntry.cs | 14 +++++----- .../System/IO/Compression/ZipCryptoStream.cs | 14 +++++----- .../System/IO/Compression/ZipCustomStreams.cs | 3 +-- 5 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index d477d0c40f6624..b8bfc547450c11 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -299,4 +299,31 @@ An attempt was made to move the position before the beginning of the stream. + + Stream size is too small for WinZip standard + + + Invalid password + + + Authentication code mismatch for WinZip encrypted entry. + + + Entry is not encrypted. + + + A password is required for encrypted entries. + + + No password was provided for encrypting this entry + + + Invalid AES strength value. + + + Truncated ZipCrypto header. + + + CRC mismatch. + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 016eeddc2d1edf..7ebd4a0ea1b09a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -97,10 +97,8 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en else { // For decryption, we must know the total size to locate the auth tag - if (_totalStreamSize <= 0) - { - throw new ArgumentException("Total stream size must be provided for decryption.", nameof(totalStreamSize)); - } + + Debug.Assert(_totalStreamSize > 0, "Total stream size must be provided for decryption."); int saltSize = _keySizeBits / 16; int headerSize = saltSize + 2; @@ -111,7 +109,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encryptedDataSize < 0) { - throw new InvalidDataException("Stream size is too small for WinZip AES format."); + throw new InvalidDataException(SR.InvalidWinZipSize);//("Stream size is too small for WinZip AES format."); } ReadHeader(password); @@ -189,7 +187,7 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException("Authentication code mismatch."); + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } _authCodeValidated = true; @@ -240,7 +238,7 @@ private void ReadHeader(ReadOnlyMemory password) if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) { - throw new InvalidDataException($"Invalid password"); + throw new InvalidDataException(SR.InvalidPassword); } Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); @@ -377,7 +375,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel { Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); - if ( _authCodeValidated) + if (_authCodeValidated) return; _hmac.TransformFinalBlock(Array.Empty(), 0, 0); @@ -406,10 +404,9 @@ private void ThrowIfNotReadable() ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); + throw new NotSupportedException(SR.ReadingNotSupported); - if (!_headerRead) - throw new InvalidOperationException("Header must be read before reading data."); + Debug.Assert(_headerRead, "Header must be read before reading data."); } private int GetBytesToRead(int requestedCount) @@ -562,7 +559,7 @@ private void ThrowIfNotWritable() ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); + throw new NotSupportedException(SR.WritingNotSupported); } public override void Write(byte[] buffer, int offset, int count) 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 a9fb101c16807a..d7ac38b8d47eb3 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 @@ -398,7 +398,7 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = case ZipArchiveMode.Read: if (!IsEncrypted) { - throw new InvalidDataException("Entry is not encrypted"); + throw new InvalidDataException(SR.EntryNotEncrypted); } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: @@ -407,7 +407,7 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!_isEncrypted) throw new InvalidDataException("Entry is not encrypted."); + if (!_isEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); return OpenInReadMode(checkOpenable: true, password.AsMemory()); } @@ -937,7 +937,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read if (IsZipCryptoEncrypted()) { if (password.IsEmpty) - throw new InvalidDataException("Password required for encrypted ZIP entry."); + throw new InvalidDataException(SR.PasswordRequired); byte expectedCheckByte = CalculateZipCryptoCheckByte(); streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); @@ -945,7 +945,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read else if (IsAesEncrypted()) { if (password.IsEmpty) - throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + throw new InvalidDataException(SR.PasswordRequired); int keySizeBits = _encryptionMethod switch { @@ -992,7 +992,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod if (encryptionMethod == EncryptionMethod.ZipCrypto) { if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException("Password is required for encryption."); + throw new InvalidOperationException(SR.NoPasswordProvided); Encryption = encryptionMethod; @@ -1009,7 +1009,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException("Password is required for encryption."); + throw new InvalidOperationException(SR.NoPasswordProvided); Encryption = encryptionMethod; @@ -1106,7 +1106,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") + _ => throw new InvalidDataException(SR.InvalidAesStrength) }; // Store the detected encryption method diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index bdda034f6c9c8a..bf20d0250cfb03 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -139,14 +139,14 @@ private void ValidateHeader(byte expectedCheckByte) } catch (EndOfStreamException) { - throw new InvalidDataException("Truncated ZipCrypto header."); + throw new InvalidDataException(SR.TruncatedZipCryptoHeader); } for (int i = 0; i < hdr.Length; i++) hdr[i] = DecryptByte(hdr[i]); if (hdr[11] != expectedCheckByte) - throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + throw new InvalidDataException(SR.InvalidPassword); } private void UpdateKeys(byte b) @@ -178,7 +178,7 @@ private byte DecryptByte(byte ciph) public override long Position { get => throw new NotSupportedException(); - set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); + set => throw new NotSupportedException(); } public override void Flush() => _base.Flush(); @@ -197,7 +197,7 @@ public override int Read(Span destination) destination[i] = DecryptByte(destination[i]); return n; } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + throw new NotSupportedException(SR.ReadingNotSupported); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); @@ -210,7 +210,7 @@ public override void Write(byte[] buffer, int offset, int count) public override void Write(ReadOnlySpan buffer) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + if (!_encrypting) throw new NotSupportedException(SR.WritingNotSupported); EnsureHeader(); @@ -267,7 +267,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation span[i] = DecryptByte(span[i]); return n; } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + throw new NotSupportedException(SR.ReadingNotSupported); } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -278,7 +278,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + if (!_encrypting) throw new NotSupportedException(SR.WritingNotSupported); cancellationToken.ThrowIfCancellationRequested(); 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 4597cdd4f0a712..acff9c9aa60179 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 @@ -791,8 +791,7 @@ private void ValidateCrc() if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Actual: 0x{_runningCrc:X8}"); + throw new InvalidDataException(SR.CrcMismatch); } } From b4deb713313aa527f70a82aa8544714c700f431e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 22 Dec 2025 11:41:26 +0200 Subject: [PATCH 24/83] nitpicks and update hashing --- .../System/IO/Compression/WinZipAesStream.cs | 136 +++++++----------- .../IO/Compression/ZipArchiveEntry.Async.cs | 8 +- .../System/IO/Compression/ZipArchiveEntry.cs | 38 ++--- 3 files changed, 72 insertions(+), 110 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 7ebd4a0ea1b09a..66d112980a505b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -19,9 +19,7 @@ internal sealed class WinZipAesStream : Stream private readonly int _keySizeBits; private readonly Aes _aes; private ICryptoTransform? _aesEncryptor; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - private readonly HMACSHA1 _hmac; -#pragma warning restore CA1416 + private IncrementalHash? _hmac; private readonly byte[] _counterBlock = new byte[BlockSize]; private byte[]? _key; private byte[]? _hmacKey; @@ -42,6 +40,7 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -49,7 +48,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _baseStream = baseStream; _encrypting = encrypting; _keySizeBits = keySizeBits; - _totalStreamSize = totalStreamSize; // Store the total size + _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); @@ -57,12 +56,6 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _aes.Mode = CipherMode.ECB; _aes.Padding = PaddingMode.None; -#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? - _hmac = new HMACSHA1(); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -#pragma warning restore CA1416 - Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; @@ -91,13 +84,14 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en DeriveKeysFromPassword(password, _salt); Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 InitCipher(); } else { // For decryption, we must know the total size to locate the auth tag - Debug.Assert(_totalStreamSize > 0, "Total stream size must be provided for decryption."); int saltSize = _keySizeBits / 16; @@ -109,13 +103,12 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encryptedDataSize < 0) { - throw new InvalidDataException(SR.InvalidWinZipSize);//("Stream size is too small for WinZip AES format."); + throw new InvalidDataException(SR.InvalidWinZipSize); } ReadHeader(password); } } - private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { // Calculate sizes @@ -163,32 +156,29 @@ private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) return; // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; + byte[] expectedAuth = _hmac.GetHashAndReset(); - if (expectedAuth is not null) - { - // Read the 10-byte stored authentication code from the stream - byte[] storedAuth = new byte[10]; + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; - if (isAsync) - { - await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.ReadExactly(storedAuth); - } - - // Compare the first 10 bytes of the expected hash - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException(SR.WinZipAuthCodeMismatch); + if (isAsync) + { + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); } + else + { + _baseStream.ReadExactly(storedAuth); + } + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); _authCodeValidated = true; } @@ -242,7 +232,9 @@ private void ReadHeader(ReadOnlyMemory password) } Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 InitCipher(); Array.Clear(_counterBlock, 0, 16); @@ -250,7 +242,6 @@ private void ReadHeader(ReadOnlyMemory password) _headerRead = true; } - private void InitCipher() { Debug.Assert(_key is not null, "_key is not null"); @@ -293,13 +284,14 @@ private Task WriteHeaderAsync(CancellationToken cancellationToken) return WriteHeaderCoreAsync(isAsync: true, cancellationToken); } - private void ProcessBlock(byte[] buffer, int offset, int count) + private void ProcessBlock(Span buffer) { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); int processed = 0; - while (processed < count) + while (processed < buffer.Length) { // Ensure we have enough keystream bytes available int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; @@ -310,9 +302,9 @@ private void ProcessBlock(byte[] buffer, int offset, int count) } // Process as many bytes as possible with the available keystream - int bytesToProcess = Math.Min(count - processed, keystreamAvailable); + int bytesToProcess = Math.Min(buffer.Length - processed, keystreamAvailable); - Span dataSpan = buffer.AsSpan(offset + processed, bytesToProcess); + Span dataSpan = buffer.Slice(processed, bytesToProcess); ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); // For encryption: XOR first, then HMAC the ciphertext @@ -321,13 +313,13 @@ private void ProcessBlock(byte[] buffer, int offset, int count) // XOR the data with the keystream to create ciphertext XorBytes(dataSpan, keystreamSpan); // HMAC is computed on the ciphertext (after XOR) - _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); + _hmac.AppendData(dataSpan); } // For decryption: HMAC first (on ciphertext), then XOR else { // HMAC is computed on the ciphertext (before XOR) - _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); + _hmac.AppendData(dataSpan); // XOR the ciphertext with the keystream to recover plaintext XorBytes(dataSpan, keystreamSpan); } @@ -336,7 +328,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) processed += bytesToProcess; } } - private void GenerateKeystreamBuffer() { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); @@ -374,31 +365,27 @@ private void IncrementCounter() private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) return; - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; + byte[] authCode = _hmac.GetHashAndReset(); - if (authCode is not null) + // WinZip AES spec requires only the first 10 bytes of the HMAC + if (isAsync) { - // WinZip AES spec requires only the first 10 bytes of the HMAC - if (isAsync) - { - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.Write(authCode, 0, 10); - } - - Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); } + else + { + _baseStream.Write(authCode, 0, 10); + } + + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); _authCodeValidated = true; } - private void ThrowIfNotReadable() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -434,15 +421,13 @@ public override int Read(Span buffer) return 0; } - // We need a byte[] for ProcessBlock due to HMAC.TransformBlock requirement - byte[] tempArray = new byte[bytesToRead]; - int bytesRead = _baseStream.Read(tempArray, 0, bytesToRead); + Span readBuffer = buffer.Slice(0, bytesToRead); + int bytesRead = _baseStream.Read(readBuffer); if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; - ProcessBlock(tempArray, 0, bytesRead); - tempArray.AsSpan(0, bytesRead).CopyTo(buffer); + ProcessBlock(readBuffer.Slice(0, bytesRead)); // Validate auth code immediately when we've read all encrypted data if (_encryptedDataRemaining <= 0) @@ -480,19 +465,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; - - // Try to process in-place if Memory is array-backed - if (MemoryMarshal.TryGetArray(buffer.Slice(0, bytesRead), out ArraySegment segment)) - { - ProcessBlock(segment.Array!, segment.Offset, bytesRead); - } - else - { - // Fallback for non-array-backed Memory (rare) - byte[] temp = buffer.Slice(0, bytesRead).ToArray(); - ProcessBlock(temp, 0, bytesRead); - temp.CopyTo(buffer.Span); - } + ProcessBlock(buffer.Span.Slice(0, bytesRead)); // Validate auth code immediately when we've read all encrypted data if (_encryptedDataRemaining <= 0) @@ -526,7 +499,7 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BlockSize); + ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); _baseStream.Write(_partialBlock, 0, BlockSize); _partialBlockBytes = 0; } @@ -539,7 +512,7 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); - ProcessBlock(workBuffer, 0, bytesToProcess); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); _baseStream.Write(workBuffer, 0, bytesToProcess); inputOffset += bytesToProcess; @@ -611,7 +584,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BlockSize); + ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BlockSize), cancellationToken).ConfigureAwait(false); _partialBlockBytes = 0; } @@ -624,7 +597,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); - ProcessBlock(workBuffer, 0, bytesToProcess); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); inputOffset += bytesToProcess; @@ -646,7 +619,7 @@ private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cance if (_partialBlockBytes > 0) { // Encrypt the partial block (ProcessBlock handles partials by XORing only available bytes) - ProcessBlock(_partialBlock, 0, _partialBlockBytes); + ProcessBlock(_partialBlock.AsSpan(0, _partialBlockBytes)); if (isAsync) { @@ -684,8 +657,7 @@ protected override void Dispose(bool disposing) { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac.Dispose(); - // Removed _encryptionBuffer.Dispose() + _hmac!.Dispose(); if (!_leaveOpen) _baseStream.Dispose(); } @@ -715,7 +687,7 @@ public override async ValueTask DisposeAsync() { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac.Dispose(); + _hmac!.Dispose(); if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 97ab25e7d56755..d66a4bded56087 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -186,11 +186,10 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Write WinZip AES extra field AFTER Zip64 (matching sync version order) // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -320,7 +319,7 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = ZipCompressionMethod.Aes; + _headerCompressionMethod = ZipCompressionMethod.Aes; var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { @@ -382,11 +381,10 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Write WinZip AES extra field if using AES encryption // Must match the exact check used in the sync version WriteLocalFileHeader - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, 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 d7ac38b8d47eb3..ef3c97d8987c8d 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 @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private ZipCompressionMethod _aesCompressionMethod; + private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -613,11 +613,10 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 WinZipAesExtraField? aesExtraField = null; int aesExtraFieldSize = 0; - if (ForAesEncryption()) + if (UseAesEncryption()) { aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -693,7 +692,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); @@ -721,11 +720,10 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption (add this block) - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -880,16 +878,10 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && !IsAesEncrypted(); + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && _headerCompressionMethod != ZipCompressionMethod.Aes; } - private bool IsAesEncrypted() - { - // Compression method 99 indicates AES encryption - return _aesCompressionMethod == ZipCompressionMethod.Aes; - } - - private bool ForAesEncryption() + private bool UseAesEncryption() { return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } @@ -942,7 +934,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read byte expectedCheckByte = CalculateZipCryptoCheckByte(); streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); } - else if (IsAesEncrypted()) + else if (_headerCompressionMethod == ZipCompressionMethod.Aes) { if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); @@ -966,7 +958,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); - if (ForAesEncryption() && _aeVersion == 1) + if (UseAesEncryption() && _aeVersion == 1) { // Wrap with CRC validator for AE-1 return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); @@ -1090,7 +1082,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = ZipCompressionMethod.Aes; + _headerCompressionMethod = ZipCompressionMethod.Aes; // AES case - need to read the extra field to determine actual compression method and encryption strength if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { @@ -1281,7 +1273,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; Debug.Assert(_crc32 == 0); } - else if (ForAesEncryption()) + else if (UseAesEncryption()) { _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; @@ -1391,7 +1383,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); @@ -1415,7 +1407,7 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { @@ -1594,7 +1586,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); @@ -1623,7 +1615,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) From 5410a045032b4083aa2a458cf3d40735dd95511f Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 6 Jan 2026 15:47:27 +0200 Subject: [PATCH 25/83] save key derivation material and replace old ctors + update tests not working yet --- .../tests/ZipFile.Encryption.cs | 406 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 167 ++++++- .../System/IO/Compression/ZipArchiveEntry.cs | 132 ++++-- .../src/System/IO/Compression/ZipBlocks.cs | 9 +- .../System/IO/Compression/ZipCryptoStream.cs | 79 +++- 5 files changed, 737 insertions(+), 56 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 50af49563fb800..501c797702bff4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -317,9 +317,413 @@ private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, actual = await r.ReadToEndAsync(); else actual = r.ReadToEnd(); - + Assert.Equal(expected, actual); } } + + #region Update Mode Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string modifiedContent = "Modified Content After Update"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify original content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, originalContent, password, async); + } + + // Open in Update mode and modify the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + // Open with password for editing + using (Stream stream = entry.Open(password)) + { + // Clear existing content and write new content + stream.SetLength(0); + byte[] newContentBytes = Encoding.UTF8.GetBytes(modifiedContent); + if (async) + await stream.WriteAsync(newContentBytes, 0, newContentBytes.Length); + else + stream.Write(newContentBytes, 0, newContentBytes.Length); + } + } + + // Verify modified content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, modifiedContent, password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_AppendToEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string appendedContent = " - Appended Text"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode and append to the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + // Seek to end and append + stream.Seek(0, SeekOrigin.End); + byte[] appendBytes = Encoding.UTF8.GetBytes(appendedContent); + if (async) + await stream.WriteAsync(appendBytes, 0, appendBytes.Length); + else + stream.Write(appendBytes, 0, appendBytes.Length); + } + } + + // Verify content has original + appended + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, originalContent + appendedContent, password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "Unmodified Content"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode, read the entry but don't modify it + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) + { + string readContent = async ? await reader.ReadToEndAsync() : reader.ReadToEnd(); + Assert.Equal(content, readContent); + } + } + + // Verify content is still intact after update mode + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, content, password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify only file2.txt + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry("file2.txt"); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Content 2"); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify: file1 and file3 unchanged, file2 modified + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + await AssertEntryTextEquals(archive.GetEntry("file1.txt"), "Content 1", password, async); + await AssertEntryTextEquals(archive.GetEntry("file2.txt"), "Modified Content 2", password, async); + await AssertEntryTextEquals(archive.GetEntry("file3.txt"), "Content 3", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Encrypted Content"); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify both entries + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Plain entry should be unchanged + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + Assert.False(plainEntry.IsEncrypted); + await AssertEntryTextEquals(plainEntry, "Plain Content", null, async); + + // Encrypted entry should be modified + var encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + Assert.True(encryptedEntry.IsEncrypted); + await AssertEntryTextEquals(encryptedEntry, "Modified Encrypted Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "large.bin"; + int originalSize = 512 * 1024; // 512KB + int modifiedSize = 768 * 1024; // 768KB + byte[] originalContent = new byte[originalSize]; + byte[] modifiedContent = new byte[modifiedSize]; + new Random(42).NextBytes(originalContent); + new Random(43).NextBytes(modifiedContent); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + // Create archive with large encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using (Stream s = entry.Open(password, encryptionMethod)) + { + if (async) + await s.WriteAsync(originalContent, 0, originalContent.Length); + else + s.Write(originalContent, 0, originalContent.Length); + } + } + + // Update with different content + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + if (async) + await stream.WriteAsync(modifiedContent, 0, modifiedContent.Length); + else + stream.Write(modifiedContent, 0, modifiedContent.Length); + } + } + + // Verify modified content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream s = entry.Open(password)) + using (MemoryStream ms = new MemoryStream()) + { + if (async) + await s.CopyToAsync(ms); + else + s.CopyTo(ms); + + Assert.Equal(modifiedContent, ms.ToArray()); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode and clear the content + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); // Make it empty + } + } + + // Verify entry is now empty + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, "", password, async); + } + } + + [Fact] + public void UpdateMode_EncryptedEntry_WrongPassword_Throws() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + Assert.Throws(() => entry.Open("wrong")); + } + } + + [Fact] + public void UpdateMode_EncryptedEntry_NoPassword_Throws() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + // Opening an encrypted entry without password in update mode should throw + Assert.ThrowsAny(() => entry.Open()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) + { + // This test verifies that modifying a ZipCrypto entry preserves encryption + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original ZipCrypto Content"; + string modifiedContent = "Modified Content"; + string password = "password123"; + + // Create archive with ZipCrypto encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify in Update mode + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes(modifiedContent); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify entry is still encrypted and can be read with original password + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, modifiedContent, password, async); + } + } + + #endregion } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 66d112980a505b..639e55a659be8b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -40,16 +40,105 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + public static int GetKeyMaterialSize(int keySizeBits) + { + int keySizeBytes = keySizeBits / 8; + // Total = encryption key + HMAC key (same size) + 2-byte password verifier + return keySizeBytes + keySizeBytes + 2; + } + + public static int GetSaltSize(int keySizeBits) => keySizeBits / 16; + + //A byte array containing salt + derived key material + public static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, int keySizeBits) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int totalKeySize = keySizeBytes + keySizeBytes + 2; // encryption key + HMAC key + verifier + + // Generate or validate salt + byte[] saltBytes; + if (salt == null) + { + saltBytes = new byte[saltSize]; + RandomNumberGenerator.Fill(saltBytes); + } + else + { + if (salt.Length != saltSize) + throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); + saltBytes = salt; + } + + // Derive keys using PBKDF2 + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + Span passwordBytes = stackalloc byte[maxPasswordByteCount]; + int actualByteCount = Encoding.UTF8.GetBytes(password.Span, passwordBytes); + Span passwordSpan = passwordBytes[..actualByteCount]; + + Span derivedKey = stackalloc byte[totalKeySize]; + + try + { + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + saltBytes, + derivedKey, + 1000, + HashAlgorithmName.SHA1); + + // Format: [salt][encryption key][HMAC key][password verifier] + byte[] result = new byte[saltSize + totalKeySize]; + saltBytes.CopyTo(result, 0); + derivedKey.CopyTo(result.AsSpan(saltSize)); - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) + return result; + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + CryptographicOperations.ZeroMemory(derivedKey); + } + } + + // Parses persisted key material into its components. + private static void ParseKeyMaterial(byte[] keyMaterial, int keySizeBits, + out byte[] salt, out byte[] encryptionKey, out byte[] hmacKey, out byte[] passwordVerifier) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int expectedSize = saltSize + keySizeBytes + keySizeBytes + 2; + + Debug.Assert(keyMaterial.Length == expectedSize, "Key material length does not match expected size."); + int offset = 0; + + salt = new byte[saltSize]; + Array.Copy(keyMaterial, offset, salt, 0, saltSize); + offset += saltSize; + + encryptionKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); + offset += keySizeBytes; + + hmacKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); + offset += keySizeBytes; + + passwordVerifier = new byte[2]; + Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); + } + + public WinZipAesStream(Stream baseStream, byte[] keyMaterial, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); + ArgumentNullException.ThrowIfNull(keyMaterial); _baseStream = baseStream; _encrypting = encrypting; _keySizeBits = keySizeBits; _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; + #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); #pragma warning restore CA1416 @@ -59,6 +148,9 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; + // Parse the persisted key material + ParseKeyMaterial(keyMaterial, keySizeBits, out _salt!, out _key!, out _hmacKey!, out _passwordVerifier!); + if (_totalStreamSize > 0) { int saltSize = _keySizeBits / 16; @@ -76,14 +168,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encrypting) { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(password, _salt); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + Debug.Assert(_hmacKey is not null, "HMAC key should be parsed"); #pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); #pragma warning restore CA5350 @@ -106,9 +191,71 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en throw new InvalidDataException(SR.InvalidWinZipSize); } - ReadHeader(password); + // Read and validate header using persisted key material + ReadHeaderWithKeyMaterial(); } } + + // Returns key material: [salt][encryption key][HMAC key][password verifier] + internal byte[] GetKeyMaterial() + { + Debug.Assert(_salt != null && _key != null && _hmacKey != null && _passwordVerifier != null, + "Keys should be initialized before getting key material"); + + int saltSize = GetSaltSize(_keySizeBits); + int keySizeBytes = _keySizeBits / 8; + int totalSize = saltSize + keySizeBytes + keySizeBytes + 2; + + byte[] result = new byte[totalSize]; + int offset = 0; + + _salt!.CopyTo(result, offset); + offset += saltSize; + + _key!.CopyTo(result, offset); + offset += keySizeBytes; + + _hmacKey!.CopyTo(result, offset); + offset += keySizeBytes; + + _passwordVerifier!.CopyTo(result, offset); + + return result; + } + + // Reads the header and validates against persisted key material. + private void ReadHeaderWithKeyMaterial() + { + if (_headerRead) return; + + // Salt size depends on AES strength + int saltSize = _keySizeBits / 16; + byte[] fileSalt = new byte[saltSize]; + _baseStream.ReadExactly(fileSalt); + + // Read the 2-byte password verifier from stream + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Verify the salt matches + Debug.Assert(fileSalt.AsSpan().SequenceEqual(_salt!), "Salt mismatch - key material does not match this entry."); + + // Verify the password verifier + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException(SR.InvalidPassword); + } + +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { // Calculate sizes 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 ef3c97d8987c8d..ce9b646ac34683 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 @@ -51,6 +51,11 @@ public partial class ZipArchiveEntry private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; + // Cached derived key material for encrypted entries to avoid repeated PBKDF2 derivation. + // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] + // For ZipCrypto: contains [key0][key1][key2] as 12 bytes + // Invalidated when encryption parameters change (new salt needed) + private byte[]? _derivedEncryptionKeyMaterial; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -886,6 +891,73 @@ private bool UseAesEncryption() return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } + private void InvalidateKeyMaterialCache() + { + _derivedEncryptionKeyMaterial = null; + } + + private int GetAesKeySizeBits() + { + return _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + } + + // Creates the appropriate decryption stream for an encrypted entry. + // Uses cached key material if available; otherwise derives keys from password and caches them. + private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) + { + if (IsZipCryptoEncrypted()) + { + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + + if (_derivedEncryptionKeyMaterial is null) + { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + + _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password); + } + + return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); + } + else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + { + int keySizeBits = GetAesKeySizeBits(); + + if (_derivedEncryptionKeyMaterial is null) + { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + + // Read salt from stream to derive keys + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); + + // Seek back so WinZipAesStream can read the header (salt + password verifier) + compressedStream.Seek(-saltSize, SeekOrigin.Current); + + // Derive and cache key material + _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + } + + return new WinZipAesStream( + baseStream: compressedStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: false, + keySizeBits: keySizeBits, + totalStreamSize: _compressedSize); + } + + // Not encrypted - return as-is + return compressedStream; + } + private Stream GetDataDecompressor(Stream compressedStreamToRead) { Stream? uncompressedStream; @@ -924,35 +996,16 @@ private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - Stream streamToDecompress = compressedStream; + Stream streamToDecompress; - if (IsZipCryptoEncrypted()) + if (IsEncrypted) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); + // Use the shared helper that handles key caching + streamToDecompress = CreateDecryptionStream(compressedStream, password); } - else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + else { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - int keySizeBits = _encryptionMethod switch - { - EncryptionMethod.Aes128 => 128, - EncryptionMethod.Aes192 => 192, - EncryptionMethod.Aes256 => 256, - _ => 256 // default for aes - }; - - streamToDecompress = new WinZipAesStream( - baseStream: compressedStream, - password: password, - encrypting: false, - keySizeBits: keySizeBits, - totalStreamSize: _compressedSize); + streamToDecompress = compressedStream; } // Get decompressed stream @@ -966,7 +1019,6 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read return decompressedStream; } - private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_everOpenedForWrite) @@ -988,12 +1040,12 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod Encryption = encryptionMethod; - // For ZipCrypto with streaming (bit 3 set), verifier = DOS time low word + byte[] keyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); targetStream = new ZipCryptoStream( baseStream: _archive.ArchiveStream, - password: password.AsMemory(), + keyBytes: keyMaterial, passwordVerifierLow2Bytes: verifierLow2Bytes, crc32: null, leaveOpen: true); @@ -1005,8 +1057,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod Encryption = encryptionMethod; - // use switch to calculate keysizebits based on encryption strength - int keysizebits = encryptionMethod switch + int keySizeBits = encryptionMethod switch { EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, @@ -1014,18 +1065,22 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod _ => 256 // Default to AES-256 }; - // targetstream should be new winzipaesstream for wrting, ae2 + // Derive key material from password with new random salt + byte[] keyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + targetStream = new WinZipAesStream( - baseStream: _archive.ArchiveStream, - password: password.AsMemory(), - encrypting: true, - keySizeBits: keysizebits, - leaveOpen: true); + baseStream: _archive.ArchiveStream, + keyMaterial: keyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true); } + bool isAesEncryption = encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128; + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128 ? false : true, + leaveBackingStreamOpen: !isAesEncryption, (object? o, EventArgs e) => { // release the archive stream @@ -1033,13 +1088,12 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod entry._archive.ReleaseArchiveStream(entry); entry._outstandingWriteStream = null; }, - encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); + streamForPosition: encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) 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 893e03cab06c95..b4fd9476c32674 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 @@ -693,12 +693,12 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? // Skip file name stream.Seek(nameLength, SeekOrigin.Current); + // Calculate end of extra fields + long extraEnd = stream.Position + extraLength; + // Parse extra fields if present if (extraLength > 0) { - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - while (stream.Position < extraEnd) { ushort headerId = reader.ReadUInt16(); @@ -723,6 +723,9 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? } } + // Ensure we're positioned at the end of extra fields (where data begins) + stream.Seek(extraEnd, SeekOrigin.Begin); + return true; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index bf20d0250cfb03..40289d320aad6f 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -10,6 +10,8 @@ namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream { + internal const int KeySize = 12; // 3 * sizeof(uint) + private readonly bool _encrypting; private readonly Stream _base; private readonly bool _leaveOpen; @@ -35,11 +37,15 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor - public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) + // Decryption constructor using persisted key bytes. + public ZipCryptoStream(Stream baseStream, byte[] keyBytes, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeysFromBytes(password.Span); + ArgumentNullException.ThrowIfNull(keyBytes); + if (keyBytes.Length != KeySize) + throw new ArgumentException($"Key bytes must be exactly {KeySize} bytes.", nameof(keyBytes)); + + InitKeysFromKeyBytes(keyBytes); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } @@ -59,6 +65,73 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } + // Encryption constructor using persisted key bytes. + public ZipCryptoStream(Stream baseStream, + byte[] keyBytes, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + ArgumentNullException.ThrowIfNull(keyBytes); + if (keyBytes.Length != KeySize) + throw new ArgumentException($"Key bytes must be exactly {KeySize} bytes.", nameof(keyBytes)); + + _encrypting = true; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = passwordVerifierLow2Bytes; + _crc32ForHeader = crc32; + InitKeysFromKeyBytes(keyBytes); + } + + // Creates the persisted key bytes from a password. + // The returned byte array contains the 3 ZipCrypto keys (key0, key1, key2) + // serialized as 12 bytes in little-endian format. + public static byte[] CreateKey(ReadOnlyMemory password) + { + // Initialize keys with standard ZipCrypto initial values + uint key0 = 305419896; + uint key1 = 591751049; + uint key2 = 878082192; + + // ZipCrypto uses raw bytes; ASCII is the most interoperable + var bytes = password.Span.ToArray(); + foreach (byte b in bytes) + { + key0 = Crc32Update(key0, b); + key1 += (key0 & 0xFF); + key1 = key1 * 134775813 + 1; + key2 = Crc32Update(key2, (byte)(key1 >> 24)); + } + + // Serialize the 3 keys to bytes in little-endian format + byte[] keyBytes = new byte[KeySize]; + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(0, 4), key0); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(4, 4), key1); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(8, 4), key2); + + return keyBytes; + } + + // Gets the current key state as a 12-byte array. + // This can be used to persist keys after header validation for update mode. + internal byte[] GetKeyBytes() + { + byte[] keyBytes = new byte[KeySize]; + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(0, 4), _key0); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(4, 4), _key1); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(8, 4), _key2); + return keyBytes; + } + + // Initializes keys from persisted key bytes. + private void InitKeysFromKeyBytes(byte[] keyBytes) + { + _key0 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(0, 4)); + _key1 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(4, 4)); + _key2 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(8, 4)); + } + private byte[] CalculateHeader() { byte[] hdrPlain = new byte[12]; From 97a4a2b7247df277fa6dcc70c2ffe34827175188 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 13 Jan 2026 14:39:18 +0100 Subject: [PATCH 26/83] working update mode for winzip & zipcrypto --- .../tests/ZipFile.Extract.cs | 119 +++++++ .../System/IO/Compression/WinZipAesStream.cs | 22 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 48 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 336 +++++++++++++++--- .../System/IO/Compression/ZipBlocks.Async.cs | 21 ++ .../src/System/IO/Compression/ZipBlocks.cs | 37 ++ 6 files changed, 533 insertions(+), 50 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index da6bcc8adf5751..a20a3b351c4f10 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1899,6 +1899,125 @@ public async Task MixedSyncAsyncOperations_AES192_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] + [Fact] + public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() + { + // Arrange - use a fixed path so you can hexdump it + Directory.CreateDirectory(DownloadsDir); + string archivePath = Path.Combine(DownloadsDir, "debug_update_mode_aes.zip"); + string archiveAfterUpdatePath = Path.Combine(DownloadsDir, "debug_update_mode_aes_after_update.zip"); + + if (File.Exists(archivePath)) File.Delete(archivePath); + if (File.Exists(archiveAfterUpdatePath)) File.Delete(archiveAfterUpdatePath); + + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + // Step 1: Create initial archive with 3 encrypted entries + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + var entries = new[] + { + ("file1.txt", "Content 1"), + ("file2.txt", "Content 2"), + ("file3.txt", "Content 3") + }; + + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var stream = entry.Open(password, encryptionMethod); + using var writer = new StreamWriter(stream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Copy the original archive for comparison + File.Copy(archivePath, Path.Combine(DownloadsDir, "debug_update_mode_aes_ORIGINAL.zip"), overwrite: true); + + // Step 2: Open in Update mode and modify file2.txt + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("file2.txt"); + Assert.NotNull(entry); + + // Log entry state before opening + System.Diagnostics.Debug.WriteLine($"Before Open - Entry: {entry.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); + System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); + System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); + + using (var stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Content 2"); + await stream.WriteAsync(newContent, 0, newContent.Length); + } + + // Log all entries' state after modification + foreach (var e in archive.Entries) + { + System.Diagnostics.Debug.WriteLine($"After Modify - Entry: {e.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {e.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {e.CompressionMethod}"); + } + } + + // Copy the modified archive for comparison + File.Copy(archivePath, archiveAfterUpdatePath, overwrite: true); + + // Step 3: Try to read back all entries + System.Diagnostics.Debug.WriteLine("=== Reading back entries ==="); + + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + foreach (var entry in archive.Entries) + { + System.Diagnostics.Debug.WriteLine($"Reading Entry: {entry.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); + System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); + System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); + + try + { + using var stream = entry.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + string content = await reader.ReadToEndAsync(); + System.Diagnostics.Debug.WriteLine($" Content: '{content}'"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($" ERROR: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + // Assert - this is where the original test fails + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + using (var s1 = archive.GetEntry("file1.txt")!.Open(password)) + using (var r1 = new StreamReader(s1)) + { + Assert.Equal("Content 1", await r1.ReadToEndAsync()); + } + + using (var s2 = archive.GetEntry("file2.txt")!.Open(password)) + using (var r2 = new StreamReader(s2)) + { + Assert.Equal("Modified Content 2", await r2.ReadToEndAsync()); + } + + using (var s3 = archive.GetEntry("file3.txt")!.Open(password)) + using (var r3 = new StreamReader(s3)) + { + Assert.Equal("Content 3", await r3.ReadToEndAsync()); + } + } + } + } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 639e55a659be8b..2a828281241400 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -789,8 +789,14 @@ protected override void Dispose(bool disposing) { try { - if (_encrypting && !_authCodeValidated && _headerWritten) + if (_encrypting && !_authCodeValidated) { + // Ensure header is written even for empty files + if (!_headerWritten) + { + WriteHeader(); + } + // Encrypt remaining partial data FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); @@ -804,7 +810,7 @@ protected override void Dispose(bool disposing) { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac!.Dispose(); + _hmac?.Dispose(); if (!_leaveOpen) _baseStream.Dispose(); } @@ -813,14 +819,21 @@ protected override void Dispose(bool disposing) _disposed = true; base.Dispose(disposing); } + public override async ValueTask DisposeAsync() { if (_disposed) return; try { - if (_encrypting && !_authCodeValidated && _headerWritten) + if (_encrypting && !_authCodeValidated) { + // Ensure header is written even for empty files + if (!_headerWritten) + { + await WriteHeaderAsync(CancellationToken.None).ConfigureAwait(false); + } + // Encrypt remaining partial data await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); @@ -834,7 +847,7 @@ public override async ValueTask DisposeAsync() { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac!.Dispose(); + _hmac?.Dispose(); if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } @@ -842,7 +855,6 @@ public override async ValueTask DisposeAsync() _disposed = true; GC.SuppressFinalize(this); } - public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index d66a4bded56087..c6c1a8936c60c1 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -202,10 +202,15 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel (ushort)CompressionMethodValues.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - } - // write extra fields (and any malformed trailing data). - await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // write extra fields excluding existing AES extra field (and any malformed trailing data). + await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); + } + else + { + // write extra fields (and any malformed trailing data). + await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } if (_fileComment.Length > 0) { @@ -397,9 +402,14 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW (ushort)CompressionMethodValues.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - } - await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // Write other extra fields, excluding any existing AES extra field to avoid duplication + await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); + } + else + { + await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } } return zip64ExtraField != null; @@ -433,8 +443,30 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can _compressedSize = 0; } + // For unchanged entries, we need to write the header correctly but avoid + // WriteLocalFileHeaderAsync creating NEW encryption structures (which would have + // wrong compression method from _compressionLevel). + // The original AES extra field is preserved in _lhUnknownExtraFields. + BitFlagValues savedFlags = _generalPurposeBitFlag; + EncryptionMethod savedEncryption = _encryptionMethod; + ZipCompressionMethod savedCompressionMethod = CompressionMethod; + + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + { + CompressionMethod = ZipCompressionMethod.Aes; + _encryptionMethod = EncryptionMethod.None; + } + await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); + // Restore original state + _generalPurposeBitFlag = savedFlags; + _encryptionMethod = savedEncryption; + CompressionMethod = savedCompressionMethod; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -444,6 +476,12 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _archive.ArchiveStream.WriteAsync(compressedBytes, cancellationToken).ConfigureAwait(false); } } + + // Write data descriptor if the original entry had one + if ((savedFlags & BitFlagValues.DataDescriptor) != 0) + { + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + } } } else // there is no data in the file (or the data in the file has not been loaded), but if we are in update mode, we may still need to write a header 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 ce9b646ac34683..22e98bba0a30f5 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 @@ -76,6 +76,8 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + // Initialize _headerCompressionMethod from the central directory + _headerCompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; _lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(cd.LastModified)); _compressedSize = cd.CompressedSize; _uncompressedSize = cd.UncompressedSize; @@ -105,7 +107,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); } - // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel) : this(archive, entryName) @@ -412,12 +413,14 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!_isEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); - return OpenInReadMode(checkOpenable: true, password.AsMemory()); + if (!_isEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } + return OpenInUpdateModeEncrypted(password); } } - /// /// Returns the FullName of the entry. /// @@ -637,7 +640,10 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 } // determine if we can fit zip64 extra field and original extra fields all in - int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); + // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one + int currExtraFieldDataLength = UseAesEncryption() + ? ZipGenericExtraField.TotalSizeExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) + : ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize + currExtraFieldDataLength; @@ -694,7 +700,12 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.VersionMadeByCompatibility] = (byte)CurrentZipPlatform; BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.VersionNeededToExtract..], (ushort)_versionToExtract); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); - BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); + + // For AES encryption, write compression method 99 (Aes) in the header + // _headerCompressionMethod preserves the original value from the central directory + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = UseAesEncryption() ? 0 : _crc32; @@ -724,7 +735,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // only write zip64ExtraField if we decided we need it (it's not null) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); - // Write AES extra field if using AES encryption (add this block) + // Write AES extra field if using AES encryption if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField @@ -741,10 +752,15 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) (ushort)CompressionMethodValues.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); - } - // write extra fields (and any malformed trailing data). - ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // write extra fields excluding existing AES extra field (and any malformed trailing data). + ZipGenericExtraField.WriteAllBlocksExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); + } + else + { + // write extra fields (and any malformed trailing data). + ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } if (_fileComment.Length > 0) { @@ -908,33 +924,36 @@ private int GetAesKeySizeBits() } // Creates the appropriate decryption stream for an encrypted entry. - // Uses cached key material if available; otherwise derives keys from password and caches them. private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) { - if (IsZipCryptoEncrypted()) + bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; + + if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - if (_derivedEncryptionKeyMaterial is null) + // Password is provided so derive fresh keys + if (!password.IsEmpty) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); + byte[] freshKeyMaterial = ZipCryptoStream.CreateKey(password); + return new ZipCryptoStream(compressedStream, freshKeyMaterial, expectedCheckByte); + } - _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password); + if (_derivedEncryptionKeyMaterial is null) + { + throw new InvalidDataException(SR.PasswordRequired); } return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); } - else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + else if (isAesEncrypted) { int keySizeBits = GetAesKeySizeBits(); - if (_derivedEncryptionKeyMaterial is null) + // Password is provided so derive fresh keys + if (!password.IsEmpty) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - // Read salt from stream to derive keys + // Generate salt from stream to derive keys int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); byte[] salt = new byte[saltSize]; compressedStream.ReadExactly(salt); @@ -942,8 +961,20 @@ private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory Array.MaxLength) + { + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Entry is too large to modify in place. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry."); + } + + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); + + if (_originallyInArchive) + { + using (Stream decompressor = OpenInReadMode(checkOpenable: false, password.AsMemory())) + { + try + { + decompressor.CopyTo(_storedUncompressedData); + } + catch (InvalidDataException) + { + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; + throw; + } + } + } + + // Derive and save key material for re-encryption + // For ZipCrypto: deterministic key from password + // For AES: generate new salt and derive fresh key material + if (IsZipCryptoEncrypted()) + { + _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + _encryptionMethod = EncryptionMethod.ZipCrypto; + } + else if (UseAesEncryption()) + { + // Generate new salt and derive key material for AES + // This ensures each write uses a fresh random salt for security + int keySizeBits = GetAesKeySizeBits(); + _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + // _encryptionMethod is already set from IsOpenable -> detected from header + } + + // Reset CRC - it will be recalculated when writing + _crc32 = 0; + + // Set the actual compression method for GetDataCompressor + // Note: For AES, CompressionMethod may currently be Aes (99) from reading the header + // We need to set it to Deflate or Stored for the actual compression + // WriteLocalFileHeader will set it back to Aes for the header + if (CompressionMethod == ZipCompressionMethod.Aes || CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) + { + CompressionMethod = ZipCompressionMethod.Deflate; + } + // else it's Stored, keep it as Stored + + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + return new WrappedStream(_storedUncompressedData, this, thisRef => + { + thisRef!._currentlyOpenForWrite = false; + }); + } private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1133,7 +1247,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); _headerCompressionMethod = ZipCompressionMethod.Aes; @@ -1196,7 +1310,7 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m } else { - if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { return true; } @@ -1394,8 +1508,11 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // save offset _offsetOfLocalHeader = _archive.ArchiveStream.Position; - // calculate extra field. if zip64 stuff + original extraField aren't going to fit, dump the original extraField, because this is more important - int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); + // Calculate extra field + // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one + int currExtraFieldDataLength = UseAesEncryption() + ? ZipGenericExtraField.TotalSizeExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) + : ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize + currExtraFieldDataLength; @@ -1434,7 +1551,11 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres ZipLocalFileHeader.SignatureConstantBytes.CopyTo(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Signature..]); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.VersionNeededToExtract..], (ushort)_versionToExtract); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); - BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); + + // For AES encryption, write compression method 99 (Aes) in the header + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = UseAesEncryption() ? 0 : _crc32; @@ -1477,10 +1598,15 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) (ushort)CompressionMethodValues.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); - } - // Write other extra fields - ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // Write other extra fields, excluding any existing AES extra field to avoid duplication + ZipGenericExtraField.WriteAllBlocksExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); + } + else + { + // Write other extra fields + ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } } return zip64ExtraField != null; @@ -1495,19 +1621,121 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) { _uncompressedSize = _storedUncompressedData.Length; - //The compressor fills in CRC and sizes - //The DirectToArchiveWriterStream writes headers and such - using (DirectToArchiveWriterStream entryWriter = new( - GetDataCompressor(_archive.ArchiveStream, true, null, null), - this)) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + if (_encryptionMethod == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + { + // Write local file header first (with encryption flag set) + // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header + WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + using (var encryptionStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + keyBytes: _derivedEncryptionKeyMaterial, + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true)) + { + // Use GetDataCompressor which handles CRC calculation and compression + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + // CRC, uncompressed size are now set by GetDataCompressor callback + // For empty files, ZipCryptoStream.Dispose() will write the 12-byte header + } + + // Calculate compressed size AFTER ZipCryptoStream is disposed + // (includes 12-byte encryption header + compressed data) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + WriteDataDescriptor(); + + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - _storedUncompressedData.CopyTo(entryWriter); + // For AES, we need to: + // 1. Write header with CompressionMethod = Aes (99) + // 2. Compress data with actual compression (Deflate/Stored) + // 3. Keep CompressionMethod = Aes for central directory + + // WriteLocalFileHeader will set CompressionMethod = Aes + WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + int keySizeBits = GetAesKeySizeBits(); + + // Determine the actual compression method to use + // The AES extra field stores the real compression method + bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; + + using (var encryptionStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true)) + { + // Only compress/write if there's data + if (_storedUncompressedData.Length > 0) + { + // Temporarily set CompressionMethod for GetDataCompressor + ZipCompressionMethod savedMethod = CompressionMethod; + CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; + + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + + // Restore CompressionMethod to Aes for central directory + CompressionMethod = ZipCompressionMethod.Aes; + } + else + { + // Empty file: CRC is 0, uncompressed size is 0 + _crc32 = 0; + _uncompressedSize = 0; + } + // WinZipAesStream.Dispose() writes salt + verifier + HMAC even for empty files + } + + // Calculate compressed size AFTER WinZipAesStream is disposed + // (includes salt + password verifier + encrypted data + HMAC) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + WriteDataDescriptor(); + + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + else + { + // Non-encrypted: use standard path + using (DirectToArchiveWriterStream entryWriter = new( + GetDataCompressor(_archive.ArchiveStream, true, null, null), + this)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(entryWriter); + } _storedUncompressedData.Dispose(); _storedUncompressedData = null; } } - else + else // _compressedBytes path - copying unchanged entry data { if (_uncompressedSize == 0) { @@ -1515,8 +1743,30 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _compressedSize = 0; } + // For unchanged entries, we need to write the header correctly but avoid + // WriteLocalFileHeader creating NEW encryption structures (which would have + // wrong compression method from _compressionLevel). + // The original AES extra field is preserved in _lhUnknownExtraFields. + BitFlagValues savedFlags = _generalPurposeBitFlag; + EncryptionMethod savedEncryption = _encryptionMethod; + ZipCompressionMethod savedCompressionMethod = CompressionMethod; + + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeader doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + { + CompressionMethod = ZipCompressionMethod.Aes; + _encryptionMethod = EncryptionMethod.None; + } + WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); + // Restore original state + _generalPurposeBitFlag = savedFlags; + _encryptionMethod = savedEncryption; + CompressionMethod = savedCompressionMethod; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -1526,6 +1776,12 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _archive.ArchiveStream.Write(compressedBytes, 0, compressedBytes.Length); } } + + // Write data descriptor if the original entry had one + if ((savedFlags & BitFlagValues.DataDescriptor) != 0) + { + WriteDataDescriptor(); + } } } else // there is no data in the file (or the data in the file has not been loaded), but if we are in update mode, we may still need to write a header 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 d5d8b8c232ebb5..b86a538b62474b 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 @@ -38,6 +38,27 @@ public static async Task WriteAllBlocksAsync(List? fields, await stream.WriteAsync(trailingExtraFieldData, cancellationToken).ConfigureAwait(false); } } + + public static async Task WriteAllBlocksExcludingTagAsync(List? fields, ReadOnlyMemory trailingExtraFieldData, Stream stream, ushort excludeTag, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + await field.WriteBlockAsync(stream, cancellationToken).ConfigureAwait(false); + } + } + } + + if (!trailingExtraFieldData.IsEmpty) + { + await stream.WriteAsync(trailingExtraFieldData, cancellationToken).ConfigureAwait(false); + } + } } internal sealed partial class Zip64ExtraField 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 b4fd9476c32674..a2e88a7679853a 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 @@ -116,6 +116,43 @@ public static void WriteAllBlocks(List? fields, ReadOnlySp stream.Write(trailingExtraFieldData); } } + + public static void WriteAllBlocksExcludingTag(List? fields, ReadOnlySpan trailingExtraFieldData, Stream stream, ushort excludeTag) + { + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + field.WriteBlock(stream); + } + } + } + + if (!trailingExtraFieldData.IsEmpty) + { + stream.Write(trailingExtraFieldData); + } + } + + public static int TotalSizeExcludingTag(List? fields, int trailingDataLength, ushort excludeTag) + { + int size = trailingDataLength; + + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + size += field.Size + ZipGenericExtraField.FieldLengths.Tag + ZipGenericExtraField.FieldLengths.Size; + } + } + } + + return size; + } } internal sealed partial class Zip64ExtraField From 6f34476f403e24c865a0d3cd85d1550046e4ba3d Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 13 Jan 2026 15:15:57 +0100 Subject: [PATCH 27/83] add more tests related to update mode --- .../tests/ZipFile.Encryption.cs | 375 +++++++++++++++++- 1 file changed, 371 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 501c797702bff4..963912eeb01fd7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -252,7 +249,7 @@ public async Task ExtractToFile_Encrypted_Success(bool async) { var entry = archive.GetEntry("test.txt"); string destFile = GetTestFilePath(); - + if (async) { await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); @@ -724,6 +721,376 @@ public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) } } + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MixedEncryption_ModifyAllEntries(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain1.txt", "Plain Content 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted1.txt", "Encrypted Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("plain2.txt", "Plain Content 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted2.txt", "Encrypted Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify all entries in Update mode + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Modify plain1.txt + ZipArchiveEntry plain1 = archive.GetEntry("plain1.txt"); + Assert.NotNull(plain1); + Assert.False(plain1.IsEncrypted); + using (Stream stream = await OpenEntryStream(async, plain1)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 1"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify encrypted1.txt (AES) + ZipArchiveEntry encrypted1 = archive.GetEntry("encrypted1.txt"); + Assert.NotNull(encrypted1); + Assert.True(encrypted1.IsEncrypted); + using (Stream stream = encrypted1.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 1"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify plain2.txt + ZipArchiveEntry plain2 = archive.GetEntry("plain2.txt"); + Assert.NotNull(plain2); + Assert.False(plain2.IsEncrypted); + using (Stream stream = await OpenEntryStream(async, plain2)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 2"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify encrypted2.txt (ZipCrypto) + ZipArchiveEntry encrypted2 = archive.GetEntry("encrypted2.txt"); + Assert.NotNull(encrypted2); + Assert.True(encrypted2.IsEncrypted); + using (Stream stream = encrypted2.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 2"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify all modifications + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + var plain1 = archive.GetEntry("plain1.txt"); + Assert.NotNull(plain1); + Assert.False(plain1.IsEncrypted); + await AssertEntryTextEquals(plain1, "Modified Plain Content 1", null, async); + + var encrypted1 = archive.GetEntry("encrypted1.txt"); + Assert.NotNull(encrypted1); + Assert.True(encrypted1.IsEncrypted); + await AssertEntryTextEquals(encrypted1, "Modified Encrypted Content 1", password, async); + + var plain2 = archive.GetEntry("plain2.txt"); + Assert.NotNull(plain2); + Assert.False(plain2.IsEncrypted); + await AssertEntryTextEquals(plain2, "Modified Plain Content 2", null, async); + + var encrypted2 = archive.GetEntry("encrypted2.txt"); + Assert.NotNull(encrypted2); + Assert.True(encrypted2.IsEncrypted); + await AssertEntryTextEquals(encrypted2, "Modified Encrypted Content 2", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("keep.txt", "Keep This Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("delete.txt", "Delete This Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("modify.txt", "Original Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify initial state + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(3, archive.Entries.Count); + } + + // Delete one entry and modify another + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete delete.txt + ZipArchiveEntry deleteEntry = archive.GetEntry("delete.txt"); + Assert.NotNull(deleteEntry); + deleteEntry.Delete(); + + // Modify modify.txt + ZipArchiveEntry modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + using (Stream stream = modifyEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify final state + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(2, archive.Entries.Count); + + // Verify deleted entry is gone + Assert.Null(archive.GetEntry("delete.txt")); + + // Verify kept entry is unchanged + var keepEntry = archive.GetEntry("keep.txt"); + Assert.NotNull(keepEntry); + Assert.True(keepEntry.IsEncrypted); + await AssertEntryTextEquals(keepEntry, "Keep This Content", password, async); + + // Verify modified entry + var modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + Assert.True(modifyEntry.IsEncrypted); + await AssertEntryTextEquals(modifyEntry, "Modified Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete encrypted entry and modify plain entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete encrypted entry + ZipArchiveEntry encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + encryptedEntry.Delete(); + + // Modify plain entry + ZipArchiveEntry plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + using (Stream stream = await OpenEntryStream(async, plainEntry)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Single(archive.Entries); + Assert.Null(archive.GetEntry("encrypted.txt")); + + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + Assert.False(plainEntry.IsEncrypted); + await AssertEntryTextEquals(plainEntry, "Modified Plain Content", null, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete plain entry and modify encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete plain entry + ZipArchiveEntry plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + plainEntry.Delete(); + + // Modify encrypted entry + ZipArchiveEntry encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + using (Stream stream = encryptedEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Single(archive.Entries); + Assert.Null(archive.GetEntry("plain.txt")); + + var encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + Assert.True(encryptedEntry.IsEncrypted); + await AssertEntryTextEquals(encryptedEntry, "Modified Encrypted Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("keep1.txt", "Keep 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("delete1.txt", "Delete 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("keep2.txt", "Keep 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("delete2.txt", "Delete 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("modify.txt", "Original", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete some entries and modify one + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + archive.GetEntry("delete1.txt")?.Delete(); + archive.GetEntry("delete2.txt")?.Delete(); + + ZipArchiveEntry modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + using (Stream stream = modifyEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(3, archive.Entries.Count); + + Assert.Null(archive.GetEntry("delete1.txt")); + Assert.Null(archive.GetEntry("delete2.txt")); + + await AssertEntryTextEquals(archive.GetEntry("keep1.txt"), "Keep 1", null, async); + await AssertEntryTextEquals(archive.GetEntry("keep2.txt"), "Keep 2", password, async); + await AssertEntryTextEquals(archive.GetEntry("modify.txt"), "Modified", password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + // Create archive with multiple entries using the same encryption method + var entries = new[] + { + ("entry1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("entry2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("entry3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Edit all entries + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + for (int i = 1; i <= 3; i++) + { + ZipArchiveEntry entry = archive.GetEntry($"entry{i}.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes($"Modified Content {i}"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + } + + // Verify all modifications + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + for (int i = 1; i <= 3; i++) + { + ZipArchiveEntry entry = archive.GetEntry($"entry{i}.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, $"Modified Content {i}", password, async); + } + } + } + #endregion } } From 30193225e55f4810c0e45107738cadf514058ed1 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 14 Jan 2026 15:33:03 +0100 Subject: [PATCH 28/83] correctly resolve conflicts --- .../tests/ZipFile.Extract.cs | 72 ++++++++++++++++++- .../IO/Compression/ZipArchiveEntry.Async.cs | 10 +-- .../System/IO/Compression/ZipArchiveEntry.cs | 36 +++------- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index a20a3b351c4f10..d559b3f343fb3b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -2018,7 +2018,77 @@ public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() } } - } + [SkipOnCI("Local development test - creates archive for manual inspection with WinRAR")] + [Fact] + public async Task Local_UpdateMode_EditAllEntries_MixedEncryption_ForWinRARInspection() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string archivePath = NewPath("update_all_entries_mixed_encryption.zip"); + string archiveBeforeUpdatePath = NewPath("update_all_entries_mixed_encryption_BEFORE.zip"); + + if (File.Exists(archivePath)) File.Delete(archivePath); + if (File.Exists(archiveBeforeUpdatePath)) File.Delete(archiveBeforeUpdatePath); + + string password = "password123"; + + var entries = new[] + { + ("entry_zipcrypto.txt", "Content ZipCrypto", ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("entry_aes128.txt", "Content AES-128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("entry_aes192.txt", "Content AES-192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("entry_aes256.txt", "Content AES-256", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Step 1: Create archive with entries using different encryption methods + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + foreach (var (name, content, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var stream = entry.Open(password, encryption); + using var writer = new StreamWriter(stream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + // Save a copy before modifications + File.Copy(archivePath, archiveBeforeUpdatePath, overwrite: true); + // Step 2: Open in Update mode and edit ALL entries + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + foreach (var (name, _, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (var stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes($"Modified {name}"); + await stream.WriteAsync(content, 0, content.Length); + } + } + } + + // Step 3: Verify all entries can be read back + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + foreach (var (name, _, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using var stream = entry.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + string content = await reader.ReadToEndAsync(); + Assert.Equal($"Modified {name}", content); + } + } + + } + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 9fb2d0b268f832..59e41de97d95c6 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -108,7 +108,7 @@ public async Task OpenAsync(string password, CancellationToken cancellat case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return await OpenInUpdateModeAsync(cancellationToken).ConfigureAwait(false); + return await OpenInUpdateModeAsync(true, cancellationToken).ConfigureAwait(false); } } @@ -254,8 +254,8 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -463,8 +463,8 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); 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 92168f2b34483b..c841647daaad5a 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 @@ -687,8 +687,8 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -802,8 +802,8 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); @@ -961,11 +961,6 @@ private bool UseAesEncryption() return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } - private void InvalidateKeyMaterialCache() - { - _derivedEncryptionKeyMaterial = null; - } - private int GetAesKeySizeBits() { return _encryptionMethod switch @@ -1112,10 +1107,10 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded _archive.DebugAssertIsStillArchiveStreamOwner(this); - return OpenInWriteModeCore(); + return OpenInWriteModeCore(password, encryptionMethod); } - private WrappedStream OpenInWriteModeCore() + private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; @@ -1529,8 +1524,8 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -1663,8 +1658,8 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); @@ -2351,17 +2346,6 @@ public enum EncryptionMethod : byte Aes256 = 4 } - - internal enum CompressionMethodValues : ushort - { - Stored = 0x0, - Deflate = 0x8, - Deflate64 = 0x9, - BZip2 = 0xC, - LZMA = 0xE, - Aes = 99 - } - internal sealed class LocalHeaderOffsetComparer : Comparer { private static readonly LocalHeaderOffsetComparer s_instance = new LocalHeaderOffsetComparer(); From af36c2adf2107db3b333b8b68f3c37dd92ec4268 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 15 Jan 2026 12:28:27 +0100 Subject: [PATCH 29/83] initial parsing of aes extra metadata --- .../tests/ZipFile.Encryption.cs | 44 ++++++++++ .../IO/Compression/ZipArchiveEntry.Async.cs | 38 ++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 81 +++++++++--------- .../src/System/IO/Compression/ZipBlocks.cs | 84 +++++++++++++++++++ 4 files changed, 185 insertions(+), 62 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 963912eeb01fd7..964b994a09b0f8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1092,5 +1092,49 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.E } #endregion + + #region CompressionMethod Property Tests for Encrypted Entries + + #region CompressionMethod Property Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompressionMethod(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + // Create archive with entries using different AES strengths + var entries = new[] + { + ("aes128.txt", "AES-128 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes192.txt", "AES-192 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes192), + ("aes256.txt", "AES-256 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("zipcrypto.txt", "ZipCrypto content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("plain.txt", "Plain content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify CompressionMethod without opening entry streams + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // AES entries should report the actual compression method from the AES extra field (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes128.txt")!.CompressionMethod); + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes192.txt")!.CompressionMethod); + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes256.txt")!.CompressionMethod); + + // ZipCrypto uses actual compression method (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("zipcrypto.txt")!.CompressionMethod); + + // Plain entry uses actual compression method (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("plain.txt")!.CompressionMethod); + } + } + + #endregion + + #endregion + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 59e41de97d95c6..b38d381cb8fabd 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -386,36 +386,32 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _headerCompressionMethod = ZipCompressionMethod.Aes; - var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // AES case - skip the local file header and validate it exists. + // The AES metadata (encryption strength, actual compression method) was already + // parsed from the central directory in the constructor, so we don't mutate + // _encryptionMethod or CompressionMethod here. + var (success, localAesField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { message = SR.LocalFileHeaderCorrupt; return (false, message); } - if (aesExtraField.HasValue) + // Optionally validate that local header AES info matches central directory. + // If local header has AES field but with mismatched strength, that's corruption. + if (localAesField.HasValue && localAesField.Value.AesStrength != (_encryptionMethod switch { - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - - _aeVersion = aesExtraField.Value.VendorVersion; - - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - // Set the compression method to the actual method for decompression - CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; + EncryptionMethod.Aes128 => 1, + EncryptionMethod.Aes192 => 2, + EncryptionMethod.Aes256 => 3, + _ => 0 + })) + { + // Local and central directory AES strengths don't match - could be corruption + // For now, we trust the central directory as authoritative (already set in constructor) } } 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 c841647daaad5a..6bd3ec1c8ff572 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 @@ -75,9 +75,33 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _versionToExtract = (ZipVersionNeededValues)cd.VersionNeededToExtract; _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; - CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; - // Initialize _headerCompressionMethod from the central directory + // Initialize _headerCompressionMethod from the central directory. + // For AES entries, this will be 99 (WinZip AES wrapper indicator) and never changes. _headerCompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + // For AES-encrypted entries, the real compression method is stored in the AES extra field (0x9901) + // Parse it now so that people can see the actual value before opening the entry. + if (_isEncrypted && cd.AesExtraField.HasValue) + { + WinZipAesExtraField aesField = cd.AesExtraField.Value; + // Set the real compression method from the AES extra field + CompressionMethod = (ZipCompressionMethod)aesField.CompressionMethod; + + // Also parse remaining needed metadata now + _aeVersion = aesField.VendorVersion; + _encryptionMethod = aesField.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException(SR.InvalidAesStrength) + }; + } + else + { + // Non-AES entry: compression method from CD is the real method + CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + } + _lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(cd.LastModified)); _compressedSize = cd.CompressedSize; _uncompressedSize = cd.UncompressedSize; @@ -1273,17 +1297,17 @@ private WrappedStream OpenInUpdateModeEncrypted(string? password) // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(); _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); - // _encryptionMethod is already set from IsOpenable -> detected from header + // _encryptionMethod is already set from constructor (parsed from central directory AES extra field) } // Reset CRC - it will be recalculated when writing _crc32 = 0; - // Set the actual compression method for GetDataCompressor - // Note: For AES, CompressionMethod may currently be Aes (99) from reading the header - // We need to set it to Deflate or Stored for the actual compression - // WriteLocalFileHeader will set it back to Aes for the header - if (CompressionMethod == ZipCompressionMethod.Aes || CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) + // Set the compression method for GetDataCompressor. + // CompressionMethod now contains the actual method (Stored/Deflate/etc.) from the + // central directory AES extra field, not the wrapper value 99. + // For re-compression, we use Deflate unless the original was Stored. + if (CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) { CompressionMethod = ZipCompressionMethod.Deflate; } @@ -1314,35 +1338,15 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _headerCompressionMethod = ZipCompressionMethod.Aes; - // AES case - need to read the extra field to determine actual compression method and encryption strength - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) + // AES case - skip the local file header and validate it exists. + // The AES metadata (encryption strength, actual compression method) was already + // parsed from the central directory in the constructor + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) { message = SR.LocalFileHeaderCorrupt; return false; } - if (aesExtraField.HasValue) - { - - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException(SR.InvalidAesStrength) - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - - _aeVersion = aesExtraField.Value.VendorVersion; - - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - // Set the compression method to the actual method for decompression - CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; - } } // Pass the detected encryption method to GetOffsetOfCompressedData @@ -1364,21 +1368,16 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m message = null; if (needToUncompress) { - if (!IsEncrypted && - CompressionMethod != ZipCompressionMethod.Stored && + // For AES-encrypted entries, CompressionMethod now contains the actual compression + // method (from the AES extra field), not the wrapper value 99. So we can use + // the same validation logic for both encrypted and non-encrypted entries. + if (CompressionMethod != ZipCompressionMethod.Stored && CompressionMethod != ZipCompressionMethod.Deflate && CompressionMethod != ZipCompressionMethod.Deflate64) { message = SR.UnsupportedCompression; return false; } - else - { - if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) - { - return true; - } - } } if (_diskNumberStart != _archive.NumberOfThisDisk) { 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 a2e88a7679853a..2ffa656a8ab88a 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 @@ -769,6 +769,7 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? internal struct WinZipAesExtraField { public const ushort HeaderId = 0x9901; + private const int DataSize = 7; // Vendor version (2) + Vendor ID (2) + AES strength (1) + Compression method (2) private ushort _vendorVersion = 2; private byte _aesStrength; private ushort _compressionMethod; @@ -786,6 +787,73 @@ public WinZipAesExtraField(ushort VendorVersion, byte AesStrength, ushort Compre public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + /// + /// Tries to find and parse the WinZip AES extra field (0x9901) from a list of generic extra fields. + /// + /// The list of extra fields to search. + /// When this method returns true, contains the parsed AES extra field. + /// true if the AES extra field was found and parsed; otherwise, false. + public static bool TryGetFromExtraFields(List? extraFields, out WinZipAesExtraField aesExtraField) + { + aesExtraField = default; + + if (extraFields == null) + return false; + + foreach (ZipGenericExtraField field in extraFields) + { + if (field.Tag == HeaderId && field.Size >= DataSize) + { + byte[] data = field.Data; + aesExtraField = new WinZipAesExtraField( + VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), + AesStrength: data[4], + CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) + ); + return true; + } + } + + return false; + } + + /// + /// Tries to find and parse the WinZip AES extra field (0x9901) from raw extra field data bytes. + /// This is used when ExtraFields are not saved (Read mode) but we still need to parse the AES field. + /// + /// The raw extra field data bytes. + /// When this method returns true, contains the parsed AES extra field. + /// true if the AES extra field was found and parsed; otherwise, false. + public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData, out WinZipAesExtraField aesExtraField) + { + aesExtraField = default; + int offset = 0; + + while (offset + 4 <= extraFieldData.Length) // Need at least 4 bytes for header ID and size + { + ushort headerId = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset, 2)); + ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset + 2, 2)); + + if (offset + 4 + fieldSize > extraFieldData.Length) + break; // Not enough data for this field + + if (headerId == HeaderId && fieldSize >= DataSize) + { + ReadOnlySpan data = extraFieldData.Slice(offset + 4, fieldSize); + aesExtraField = new WinZipAesExtraField( + VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), + AesStrength: data[4], + CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) + ); + return true; + } + + offset += 4 + fieldSize; + } + + return false; + } + public void WriteBlock(Stream stream) { Span buffer = new byte[TotalSize]; @@ -847,6 +915,14 @@ internal sealed partial class ZipCentralDirectoryFileHeader public List? ExtraFields; public byte[]? TrailingExtraFieldData; + /// + /// The WinZip AES extra field (0x9901) if present in the central directory. + /// This is always parsed (regardless of saveExtraFieldsAndComments) so that + /// ZipArchiveEntry can determine the real compression method for AES-encrypted entries + /// without needing to read the local file header. + /// + public WinZipAesExtraField? AesExtraField; + private static bool TryReadBlockInitialize(ReadOnlySpan buffer, [NotNullWhen(returnValue: true)] out ZipCentralDirectoryFileHeader? header, out int bytesRead, out uint compressedSizeSmall, out uint uncompressedSizeSmall, out ushort diskNumberStartSmall, out uint relativeOffsetOfLocalHeaderSmall) { // the buffer will always be large enough for at least the constant section to be verified @@ -898,6 +974,14 @@ private static void TryReadBlockFinalize(ZipCentralDirectoryFileHeader header, R ReadOnlySpan zipExtraFields = dynamicHeader.Slice(header.FilenameLength, header.ExtraFieldLength); + // Always parse AES extra field (0x9901) from the central directory if present. + // This is needed so ZipArchiveEntry can determine the real compression method + // for AES-encrypted entries without requiring Open() to be called. + if (WinZipAesExtraField.TryGetFromRawExtraFieldData(zipExtraFields, out WinZipAesExtraField aesField)) + { + header.AesExtraField = aesField; + } + if (saveExtraFieldsAndComments) { header.ExtraFields = ZipGenericExtraField.ParseExtraField(zipExtraFields, out ReadOnlySpan trailingDataSpan); From 38dc088569aacf271597951fa83c5965ae686b3f Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 16 Jan 2026 15:04:14 +0100 Subject: [PATCH 30/83] address comments --- .../System/IO/Compression/ZipTestHelper.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 2 +- .../tests/ZipFile.Encryption.cs | 397 ++++++++---------- .../ref/System.IO.Compression.cs | 4 +- .../src/System.IO.Compression.csproj | 1 - .../IO/Compression/ZipArchiveEntry.Async.cs | 259 +++++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 303 ++++--------- .../System/IO/Compression/ZipBlocks.Async.cs | 67 +-- .../src/System/IO/Compression/ZipBlocks.cs | 78 +--- 9 files changed, 438 insertions(+), 675 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a3ccd52901601e..57782feb62b8e2 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync() : entry.Open(); + return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 5e4a4604db53aa..f097e93b36ba01 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -100,7 +100,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { Stream es; if (!string.IsNullOrEmpty(password)) - es = await source.OpenAsync(password, cancellationToken).ConfigureAwait(false); + es = await source.OpenAsync(password, cancellationToken: cancellationToken).ConfigureAwait(false); else es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); await using (es) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 964b994a09b0f8..9aa43bbe8d617d 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -72,30 +72,6 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEn } } - [Theory] - [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) - { - string archivePath = GetTempArchivePath(); - var entries = new[] - { - ("file1.txt", "Content 1", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), - ("file2.txt", "Content 2", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) - }; - - await CreateArchiveWithEntries(archivePath, entries, async); - - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - foreach (var (name, content, pwd, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - await AssertEntryTextEquals(entry, content, pwd, async); - } - } - } - [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) @@ -193,7 +169,7 @@ public async Task Encryption_LargeFile_RoundTrip(bool async) } [Fact] - public void Negative_WrongPassword_Throws_InvalidDataException() + public void WrongPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); string password = "correct"; @@ -222,17 +198,19 @@ public void Negative_MissingPassword_Throws_InvalidDataException() } } - [Fact] - public void Negative_OpeningPlainEntryWithPassword_Throws() + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Negative_OpeningPlainEntryWithPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); var entries = new[] { ("plain.txt", "content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) }; - CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + await CreateArchiveWithEntries(archivePath, entries, async); - using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) { - var entry = archive.GetEntry("plain.txt"); - Assert.ThrowsAny(() => entry.Open("password")); + ZipArchiveEntry entry = archive.GetEntry("plain.txt"); + Assert.NotNull(entry); + Assert.Throws(() => entry.Open("password")); } } @@ -374,48 +352,6 @@ public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipArchiveEntry.Encr } } - [Theory] - [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task UpdateMode_AppendToEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) - { - string archivePath = GetTempArchivePath(); - string entryName = "test.txt"; - string originalContent = "Original Content"; - string appendedContent = " - Appended Text"; - string password = "password123"; - - // Create archive with encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; - await CreateArchiveWithEntries(archivePath, entries, async); - - // Open in Update mode and append to the encrypted entry - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using (Stream stream = entry.Open(password)) - { - // Seek to end and append - stream.Seek(0, SeekOrigin.End); - byte[] appendBytes = Encoding.UTF8.GetBytes(appendedContent); - if (async) - await stream.WriteAsync(appendBytes, 0, appendBytes.Length); - else - stream.Write(appendBytes, 0, appendBytes.Length); - } - } - - // Verify content has original + appended - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - await AssertEntryTextEquals(entry, originalContent + appendedContent, password, async); - } - } - [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) @@ -674,152 +610,7 @@ public void UpdateMode_EncryptedEntry_NoPassword_Throws() var entry = archive.GetEntry("test.txt"); Assert.NotNull(entry); // Opening an encrypted entry without password in update mode should throw - Assert.ThrowsAny(() => entry.Open()); - } - } - - [Theory] - [MemberData(nameof(Get_Booleans_Data))] - public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) - { - // This test verifies that modifying a ZipCrypto entry preserves encryption - string archivePath = GetTempArchivePath(); - string entryName = "test.txt"; - string originalContent = "Original ZipCrypto Content"; - string modifiedContent = "Modified Content"; - string password = "password123"; - - // Create archive with ZipCrypto encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) }; - await CreateArchiveWithEntries(archivePath, entries, async); - - // Modify in Update mode - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - - using (Stream stream = entry.Open(password)) - { - stream.SetLength(0); - byte[] newContent = Encoding.UTF8.GetBytes(modifiedContent); - if (async) - await stream.WriteAsync(newContent, 0, newContent.Length); - else - stream.Write(newContent, 0, newContent.Length); - } - } - - // Verify entry is still encrypted and can be read with original password - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - await AssertEntryTextEquals(entry, modifiedContent, password, async); - } - } - - [Theory] - [MemberData(nameof(Get_Booleans_Data))] - public async Task UpdateMode_MixedEncryption_ModifyAllEntries(bool async) - { - string archivePath = GetTempArchivePath(); - string password = "password123"; - - var entries = new[] - { - ("plain1.txt", "Plain Content 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), - ("encrypted1.txt", "Encrypted Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), - ("plain2.txt", "Plain Content 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), - ("encrypted2.txt", "Encrypted Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) - }; - - await CreateArchiveWithEntries(archivePath, entries, async); - - // Modify all entries in Update mode - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - // Modify plain1.txt - ZipArchiveEntry plain1 = archive.GetEntry("plain1.txt"); - Assert.NotNull(plain1); - Assert.False(plain1.IsEncrypted); - using (Stream stream = await OpenEntryStream(async, plain1)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 1"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify encrypted1.txt (AES) - ZipArchiveEntry encrypted1 = archive.GetEntry("encrypted1.txt"); - Assert.NotNull(encrypted1); - Assert.True(encrypted1.IsEncrypted); - using (Stream stream = encrypted1.Open(password)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 1"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify plain2.txt - ZipArchiveEntry plain2 = archive.GetEntry("plain2.txt"); - Assert.NotNull(plain2); - Assert.False(plain2.IsEncrypted); - using (Stream stream = await OpenEntryStream(async, plain2)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 2"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify encrypted2.txt (ZipCrypto) - ZipArchiveEntry encrypted2 = archive.GetEntry("encrypted2.txt"); - Assert.NotNull(encrypted2); - Assert.True(encrypted2.IsEncrypted); - using (Stream stream = encrypted2.Open(password)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 2"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - } - - // Verify all modifications - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - var plain1 = archive.GetEntry("plain1.txt"); - Assert.NotNull(plain1); - Assert.False(plain1.IsEncrypted); - await AssertEntryTextEquals(plain1, "Modified Plain Content 1", null, async); - - var encrypted1 = archive.GetEntry("encrypted1.txt"); - Assert.NotNull(encrypted1); - Assert.True(encrypted1.IsEncrypted); - await AssertEntryTextEquals(encrypted1, "Modified Encrypted Content 1", password, async); - - var plain2 = archive.GetEntry("plain2.txt"); - Assert.NotNull(plain2); - Assert.False(plain2.IsEncrypted); - await AssertEntryTextEquals(plain2, "Modified Plain Content 2", null, async); - - var encrypted2 = archive.GetEntry("encrypted2.txt"); - Assert.NotNull(encrypted2); - Assert.True(encrypted2.IsEncrypted); - await AssertEntryTextEquals(encrypted2, "Modified Encrypted Content 2", password, async); + Assert.ThrowsAny(() => entry.Open()); } } @@ -1136,5 +927,173 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression #endregion + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + // Clean up any leftover file from previous failed runs + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + + // Skip if insufficient disk space + long requiredSpace = 5L * 1024 * 1024 * 1024; // 5GB + DriveInfo drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(archivePath))!); + if (drive.AvailableFreeSpace < requiredSpace * 2) // Need space for archive + verification + { + return; // Skip test - insufficient disk space + } + + string entryName = "zip64_true_large.bin"; + long size = (long)uint.MaxValue + (1024 * 1024); // Just over 4GB + string password = "Zip64Password!"; + int bufferSize = 64 * 1024 * 1024; // 64MB buffer + + // Create a deterministic buffer for writing and verification + byte[] buffer = new byte[bufferSize]; + new Random(42).NextBytes(buffer); + + // Create archive with entry > 4GB to force Zip64 + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); + using (Stream s = entry.Open(password, encryptionMethod)) + { + long written = 0; + while (written < size) + { + int toWrite = (int)Math.Min(buffer.Length, size - written); + if (async) + await s.WriteAsync(buffer.AsMemory(0, toWrite)); + else + s.Write(buffer, 0, toWrite); + written += toWrite; + } + } + } + + // Verify the archive was created and can be read + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + Assert.Equal(size, entry.Length); + + // Verify content by reading in chunks and checking pattern + using (Stream s = entry.Open(password)) + { + byte[] readBuffer = new byte[bufferSize]; + long totalRead = 0; + + while (totalRead < size) + { + int expectedToRead = (int)Math.Min(readBuffer.Length, size - totalRead); + int actualRead = 0; + while (actualRead < expectedToRead) + { + int bytesRead = async + ? await s.ReadAsync(readBuffer.AsMemory(actualRead, expectedToRead - actualRead)) + : s.Read(readBuffer, actualRead, expectedToRead - actualRead); + if (bytesRead == 0) break; + actualRead += bytesRead; + } + + Assert.Equal(expectedToRead, actualRead); + Assert.True(readBuffer.AsSpan(0, actualRead).SequenceEqual(buffer.AsSpan(0, actualRead))); + totalRead += actualRead; + } + + Assert.Equal(size, totalRead); + } + } + } + finally + { + // Clean up the large file + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + // Clean up any leftover file from previous failed runs + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + + // Skip if insufficient disk space + long requiredSpace = 5L * 1024 * 1024 * 1024; // 5GB + DriveInfo drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(archivePath))!); + if (drive.AvailableFreeSpace < requiredSpace * 2) + { + return; // Skip test - insufficient disk space + } + + string entryName = "zip64_true_large.bin"; + long size = (long)uint.MaxValue + (1024 * 1024); // Just over 4GB + string password = "Zip64Password!"; + int bufferSize = 64 * 1024 * 1024; // 64MB buffer + + byte[] buffer = new byte[bufferSize]; + new Random(42).NextBytes(buffer); + + // Create archive with entry > 4GB to force Zip64 + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); + using (Stream s = entry.Open(password, encryptionMethod)) + { + long written = 0; + while (written < size) + { + int toWrite = (int)Math.Min(buffer.Length, size - written); + if (async) + await s.WriteAsync(buffer.AsMemory(0, toWrite)); + else + s.Write(buffer, 0, toWrite); + written += toWrite; + } + } + } + + // Update mode should fail for entries larger than int.MaxValue + // because the implementation loads the entire decrypted content into a MemoryStream + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + // Opening the entry in update mode requires decrypting into memory, + // which should fail for entries larger than memorystream MaxValue (~2GB) + Assert.ThrowsAny(() => entry.Open(password)); + } + } + finally + { + // Clean up the large file + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + } } 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 38d4cf63b7e0e0..31bb46529441da 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -127,8 +127,8 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } - public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.ZipCrypto) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Stream Open(FileAccess access) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 994062f96a0a40..0948379d4e1cf6 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -81,7 +81,6 @@ - \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index b38d381cb8fabd..2502b91c24fd1d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -94,7 +94,7 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell } } - public async Task OpenAsync(string password, CancellationToken cancellationToken = default) + public async Task OpenAsync(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -102,13 +102,21 @@ public async Task OpenAsync(string password, CancellationToken cancellat switch (_archive.Mode) { case ZipArchiveMode.Read: + if (!IsEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); case ZipArchiveMode.Create: - return OpenInWriteMode(); + return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return await OpenInUpdateModeAsync(true, cancellationToken).ConfigureAwait(false); + if (!IsEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } + return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false); } } @@ -120,58 +128,36 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - long baseOffset; - - if (!IsEncrypted || IsZipCryptoEncrypted()) - { - // Non-AES case: just skip the local header - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - } - else - { - // AES case - need to parse the AES extra field to find the actual compression method and skip the correct number of bytes - var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - if (!success) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + // Skip the local file header to get to the compressed data + // TrySkipBlockAsync handles both AES and non-AES cases correctly + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - baseOffset = _archive.ArchiveStream.Position; - } - - _storedOffsetOfCompressedData = baseOffset; + _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } return _storedOffsetOfCompressedData.Value; } - private async Task GetUncompressedDataAsync(CancellationToken cancellationToken) + private async Task GetUncompressedDataAsync(CancellationToken cancellationToken, string? password = null) { cancellationToken.ThrowIfCancellationRequested(); if (_storedUncompressedData == null) { // this means we have never opened it before - // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just - // grow as data is copied into it + if (_uncompressedSize > Array.MaxLength) + { + throw new InvalidDataException(SR.EntryTooLarge); + } + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { - if (_isEncrypted) - { - // We don't support edit-in-place for encrypted entries without an explicit password flow. - // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. - await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Editing an encrypted entry in-place is not supported. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); - } + Stream decompressor = password != null + ? await OpenInReadModeAsync(checkOpenable: false, cancellationToken, password.AsMemory()).ConfigureAwait(false) + : await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); - Stream decompressor = await OpenInReadModeAsync(false, cancellationToken).ConfigureAwait(false); await using (decompressor) { try @@ -189,6 +175,7 @@ private async Task GetUncompressedDataAsync(CancellationToken canc _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; throw; } } @@ -244,20 +231,7 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); // write extra fields excluding existing AES extra field (and any malformed trailing data). await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); @@ -333,12 +307,16 @@ private async Task OpenInReadModeAsync(bool checkOpenable, CancellationT await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false), password); } - private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default) + private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default, string? password = null) { cancellationToken.ThrowIfCancellationRequested(); if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); + // Validate password requirement for encrypted entries + if (loadExistingContent && IsEncrypted && string.IsNullOrEmpty(password)) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + if (loadExistingContent) { await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); @@ -350,7 +328,13 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent if (loadExistingContent) { - _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken).ConfigureAwait(false); + _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken, password).ConfigureAwait(false); + + // For encrypted entries, set up key material for re-encryption + if (IsEncrypted) + { + SetupEncryptionKeyMaterial(password!); + } } else { @@ -391,28 +375,12 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. // The AES metadata (encryption strength, actual compression method) was already - // parsed from the central directory in the constructor, so we don't mutate - // _encryptionMethod or CompressionMethod here. - var (success, localAesField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - if (!success) + // parsed from the central directory in the constructor. + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) { message = SR.LocalFileHeaderCorrupt; return (false, message); } - - // Optionally validate that local header AES info matches central directory. - // If local header has AES field but with mismatched strength, that's corruption. - if (localAesField.HasValue && localAesField.Value.AesStrength != (_encryptionMethod switch - { - EncryptionMethod.Aes128 => 1, - EncryptionMethod.Aes192 => 2, - EncryptionMethod.Aes256 => 3, - _ => 0 - })) - { - // Local and central directory AES strengths don't match - could be corruption - // For now, we trust the central directory as authoritative (already set in constructor) - } } // when this property gets called, some duplicated work @@ -449,20 +417,7 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Must match the exact check used in the sync version WriteLocalFileHeader if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); // Write other extra fields, excluding any existing AES extra field to avoid duplication await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); @@ -485,18 +440,126 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can { _uncompressedSize = _storedUncompressedData.Length; - //The compressor fills in CRC and sizes - //The DirectToArchiveWriterStream writes headers and such - DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); - await using (entryWriter) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - await _storedUncompressedData.CopyToAsync(entryWriter, cancellationToken).ConfigureAwait(false); + // Write local file header first (with encryption flag set) + // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + var encryptionStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + keyBytes: _derivedEncryptionKeyMaterial, + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true); + await using (encryptionStream.ConfigureAwait(false)) + { + // Use GetDataCompressor which handles CRC calculation and compression + var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + await using (crcStream.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + } + // CRC, uncompressed size are now set by GetDataCompressor callback + // For empty files, ZipCryptoStream.Dispose() will write the 12-byte header + } + + // Calculate compressed size AFTER ZipCryptoStream is disposed + // (includes 12-byte encryption header + compressed data) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) + { + // For AES, we need to: + // 1. Write header with CompressionMethod = Aes (99) + // 2. Compress data with actual compression (Deflate/Stored) + // 3. Keep CompressionMethod = Aes for central directory + + // WriteLocalFileHeaderAsync will set CompressionMethod = Aes + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + int keySizeBits = GetAesKeySizeBits(); + + // Determine the actual compression method to use + // The AES extra field stores the real compression method + bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; + + var encryptionStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true); + await using (encryptionStream.ConfigureAwait(false)) + { + // Only compress/write if there's data + if (_storedUncompressedData.Length > 0) + { + // Temporarily set CompressionMethod for GetDataCompressor + ZipCompressionMethod savedMethod = CompressionMethod; + CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; + + var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + await using (crcStream.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + } + + // Restore CompressionMethod to Aes for central directory + CompressionMethod = ZipCompressionMethod.Aes; + } + else + { + // Empty file: CRC is 0, uncompressed size is 0 + _crc32 = 0; + _uncompressedSize = 0; + } + // WinZipAesStream.Dispose() writes salt + verifier + HMAC even for empty files + } + + // Calculate compressed size AFTER WinZipAesStream is disposed + // (includes salt + password verifier + encrypted data + HMAC) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + else + { + // Non-encrypted: use standard path + //The compressor fills in CRC and sizes + //The DirectToArchiveWriterStream writes headers and such + DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); + await using (entryWriter.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(entryWriter, cancellationToken).ConfigureAwait(false); + } await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; } } - else + else // _compressedBytes path - copying unchanged entry data { if (_uncompressedSize == 0) { @@ -509,23 +572,23 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = _encryptionMethod; + EncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new + // but clear Encryption so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { CompressionMethod = ZipCompressionMethod.Aes; - _encryptionMethod = EncryptionMethod.None; + Encryption = EncryptionMethod.None; } await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); // Restore original state _generalPurposeBitFlag = savedFlags; - _encryptionMethod = savedEncryption; + Encryption = savedEncryption; CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data 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 6bd3ec1c8ff572..2cbefbba894a77 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 @@ -51,10 +51,9 @@ public partial class ZipArchiveEntry private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; - // Cached derived key material for encrypted entries to avoid repeated PBKDF2 derivation. + // Cached derived key material for encrypted entries to allow updating in place // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] // For ZipCrypto: contains [key0][key1][key2] as 12 bytes - // Invalidated when encryption parameters change (new salt needed) private byte[]? _derivedEncryptionKeyMaterial; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -88,7 +87,7 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) // Also parse remaining needed metadata now _aeVersion = aesField.VendorVersion; - _encryptionMethod = aesField.AesStrength switch + Encryption = aesField.AesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, @@ -420,7 +419,7 @@ public Stream Open() /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256) { ThrowIfInvalidArchive(); switch (_archive.Mode) @@ -436,13 +435,11 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - - if (!_isEncrypted) + if (!IsEncrypted) { throw new InvalidDataException(SR.EntryNotEncrypted); } - - return OpenInUpdateModeEncrypted(password); + return OpenInUpdateMode(loadExistingContent: true, password); } } /// @@ -534,27 +531,12 @@ internal long GetOffsetOfCompressedData() // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - long baseOffset; - - if (!IsEncrypted || IsZipCryptoEncrypted()) - { - // Non-AES case: just skip the local header - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - } - else - { - // AES case - need to also parse the AES extra field and skip the correct number of bytes - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - - } + // Skip the local file header to get to the compressed data + // TrySkipBlock handles both AES and non-AES cases correctly + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = baseOffset; + _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } return _storedOffsetOfCompressedData.Value; @@ -566,27 +548,21 @@ private MemoryStream GetUncompressedData(string? password = null) { // this means we have never opened it before - // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just - // grow as data is copied into it + + if (_uncompressedSize > Array.MaxLength) + { + throw new InvalidDataException(SR.EntryTooLarge); + } + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { + Stream decompressor = password != null + ? OpenInReadMode(checkOpenable: false, password.AsMemory()) + : OpenInReadMode(checkOpenable: false); - if (_isEncrypted) - { - // We don’t support edit-in-place for encrypted entries without an explicit password flow. - // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. - _storedUncompressedData.Dispose(); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Editing an encrypted entry in-place is not supported. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); - } - - using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) + using (decompressor) { try { @@ -603,6 +579,7 @@ private MemoryStream GetUncompressedData(string? password = null) _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; throw; } } @@ -701,19 +678,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 if (UseAesEncryption()) { - aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; + aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -816,20 +781,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // Write AES extra field if using AES encryption if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - aesExtraField.WriteBlock(_archive.ArchiveStream); + CreateAesExtraField().WriteBlock(_archive.ArchiveStream); // write extra fields excluding existing AES extra field (and any malformed trailing data). ZipGenericExtraField.WriteAllBlocksExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); @@ -982,12 +934,12 @@ private bool IsZipCryptoEncrypted() private bool UseAesEncryption() { - return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; + return Encryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } private int GetAesKeySizeBits() { - return _encryptionMethod switch + return Encryption switch { EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, @@ -997,62 +949,37 @@ private int GetAesKeySizeBits() } // Creates the appropriate decryption stream for an encrypted entry. - private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) + private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemory password) { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - - // Password is provided so derive fresh keys - if (!password.IsEmpty) - { - byte[] freshKeyMaterial = ZipCryptoStream.CreateKey(password); - return new ZipCryptoStream(compressedStream, freshKeyMaterial, expectedCheckByte); - } - - if (_derivedEncryptionKeyMaterial is null) - { - throw new InvalidDataException(SR.PasswordRequired); - } - - return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); + byte[] keyMaterial = ZipCryptoStream.CreateKey(password); + return new ZipCryptoStream(compressedStream, keyMaterial, expectedCheckByte); } else if (isAesEncrypted) { int keySizeBits = GetAesKeySizeBits(); - // Password is provided so derive fresh keys - if (!password.IsEmpty) - { - // Generate salt from stream to derive keys - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - compressedStream.ReadExactly(salt); - - // Seek back so WinZipAesStream can read the header (salt + password verifier) - compressedStream.Seek(-saltSize, SeekOrigin.Current); - - // Derive fresh key material from the provided password - byte[] freshKeyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); - - return new WinZipAesStream( - baseStream: compressedStream, - keyMaterial: freshKeyMaterial, - encrypting: false, - keySizeBits: keySizeBits, - totalStreamSize: _compressedSize); - } + // Read salt from stream to derive keys + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); - if (_derivedEncryptionKeyMaterial is null) - { - throw new InvalidDataException(SR.PasswordRequired); - } + // Seek back so WinZipAesStream can read the header (salt + password verifier) + compressedStream.Seek(-saltSize, SeekOrigin.Current); + + // Derive key material from the provided password + byte[] keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); return new WinZipAesStream( baseStream: compressedStream, - keyMaterial: _derivedEncryptionKeyMaterial, + keyMaterial: keyMaterial, encrypting: false, keySizeBits: keySizeBits, totalStreamSize: _compressedSize); @@ -1061,7 +988,6 @@ private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory + /// Sets up encryption key material for re-encryption when writing back to the archive. + /// + private void SetupEncryptionKeyMaterial(string password) { - if (_currentlyOpenForWrite) - throw new IOException(SR.UpdateModeOneStream); - - ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); - - if (string.IsNullOrEmpty(password)) - throw new ArgumentException(SR.PasswordRequired, nameof(password)); - - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; - _currentlyOpenForWrite = true; - - // Load and decrypt the data into memory - // MemoryStream has a maximum capacity of int.MaxValue bytes - if (_uncompressedSize > Array.MaxLength) - { - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Entry is too large to modify in place. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry."); - } - - _storedUncompressedData = new MemoryStream((int)_uncompressedSize); - - if (_originallyInArchive) - { - using (Stream decompressor = OpenInReadMode(checkOpenable: false, password.AsMemory())) - { - try - { - decompressor.CopyTo(_storedUncompressedData); - } - catch (InvalidDataException) - { - _storedUncompressedData.Dispose(); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - _derivedEncryptionKeyMaterial = null; - throw; - } - } - } - // Derive and save key material for re-encryption - // For ZipCrypto: deterministic key from password - // For AES: generate new salt and derive fresh key material if (IsZipCryptoEncrypted()) { _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); - _encryptionMethod = EncryptionMethod.ZipCrypto; + Encryption = EncryptionMethod.ZipCrypto; } else if (UseAesEncryption()) { @@ -1297,28 +1189,33 @@ private WrappedStream OpenInUpdateModeEncrypted(string? password) // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(); _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); - // _encryptionMethod is already set from constructor (parsed from central directory AES extra field) + // Encryption is already set from constructor (parsed from central directory AES extra field) } // Reset CRC - it will be recalculated when writing _crc32 = 0; + } - // Set the compression method for GetDataCompressor. - // CompressionMethod now contains the actual method (Stored/Deflate/etc.) from the - // central directory AES extra field, not the wrapper value 99. - // For re-compression, we use Deflate unless the original was Stored. - if (CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) - { - CompressionMethod = ZipCompressionMethod.Deflate; - } - // else it's Stored, keep it as Stored - - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - return new WrappedStream(_storedUncompressedData, this, thisRef => + /// + /// Creates a WinZip AES extra field for writing to local/central directory headers. + /// + private WinZipAesExtraField CreateAesExtraField() + { + return new WinZipAesExtraField { - thisRef!._currentlyOpenForWrite = false; - }); + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 // Default to AES-256 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression + ? (ushort)ZipCompressionMethod.Stored + : (ushort)ZipCompressionMethod.Deflate + }; } + private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1341,12 +1238,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st // AES case - skip the local file header and validate it exists. // The AES metadata (encryption strength, actual compression method) was already // parsed from the central directory in the constructor - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; return false; } - } // Pass the detected encryption method to GetOffsetOfCompressedData @@ -1466,7 +1362,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; - internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } + internal EncryptionMethod Encryption { get => _encryptionMethod; private set => _encryptionMethod = value; } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { @@ -1513,19 +1409,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o CompressionMethod = ZipCompressionMethod.Aes; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; - aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; + aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit @@ -1647,20 +1531,7 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) // Write AES extra field if using AES encryption if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - aesExtraField.WriteBlock(_archive.ArchiveStream); + CreateAesExtraField().WriteBlock(_archive.ArchiveStream); // Write other extra fields, excluding any existing AES extra field to avoid duplication ZipGenericExtraField.WriteAllBlocksExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); @@ -1685,7 +1556,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (_encryptionMethod == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) { // Write local file header first (with encryption flag set) // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header @@ -1811,7 +1682,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = _encryptionMethod; + EncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; // For AES entries: set CompressionMethod to Aes so header writes method 99, @@ -1820,14 +1691,14 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { CompressionMethod = ZipCompressionMethod.Aes; - _encryptionMethod = EncryptionMethod.None; + Encryption = EncryptionMethod.None; } WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); // Restore original state _generalPurposeBitFlag = savedFlags; - _encryptionMethod = savedEncryption; + Encryption = savedEncryption; CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data 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 b86a538b62474b..a11d7833778555 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 @@ -168,79 +168,14 @@ public static async Task TrySkipBlockAsync(Stream stream, CancellationToke cancellationToken.ThrowIfCancellationRequested(); byte[] blockBytes = new byte[FieldLengths.Signature]; - long currPosition = stream.Position; int bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); - if (!TrySkipBlockCore(stream, blockBytes, bytesRead, currPosition)) + if (!TrySkipBlockCore(stream, blockBytes, bytesRead)) { return false; } bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - - public static async Task<(bool success, WinZipAesExtraField? aesExtraField)> TrySkipBlockAESAwareAsync(Stream stream, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - WinZipAesExtraField? aesExtraField = null; - - // Read the first 4 bytes (local file header signature) - byte[] signatureBytes = new byte[4]; - await stream.ReadExactlyAsync(signatureBytes, cancellationToken).ConfigureAwait(false); - if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) - { - return (false, null); // Not a valid local file header - } - - // Read fixed-size fields after signature - // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) - byte[] skipBuffer = new byte[22]; - await stream.ReadExactlyAsync(skipBuffer, cancellationToken).ConfigureAwait(false); - - byte[] lengthBuffer = new byte[4]; - await stream.ReadExactlyAsync(lengthBuffer, cancellationToken).ConfigureAwait(false); - ushort nameLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(0, 2)); - ushort extraLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(2, 2)); - - // Skip file name - stream.Seek(nameLength, SeekOrigin.Current); - - // Parse extra fields if present - if (extraLength > 0) - { - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - - byte[] fieldHeader = new byte[4]; - while (stream.Position < extraEnd) - { - await stream.ReadExactlyAsync(fieldHeader, cancellationToken).ConfigureAwait(false); - ushort headerId = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(0, 2)); - ushort dataSize = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(2, 2)); - - if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - byte[] aesData = new byte[7]; - await stream.ReadExactlyAsync(aesData, cancellationToken).ConfigureAwait(false); - ushort vendorVersion = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(0, 2)); - // Skip vendor ID 'AE' (bytes 2-3) - byte aesStrength = aesData[4]; // 1, 2, or 3 - ushort compressionMethod = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(5, 2)); - - aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); - break; - } - else - { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field - } - } - } - - return (true, aesExtraField); - } } 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 2ffa656a8ab88a..47b34e5a4ce755 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 @@ -650,18 +650,13 @@ public static List GetExtraFields(Stream stream, out byte[ } } - private static bool TrySkipBlockCore(Stream stream, Span blockBytes, int bytesRead, long currPosition) + private static bool TrySkipBlockCore(Stream stream, Span blockBytes, int bytesRead) { if (bytesRead != FieldLengths.Signature || !blockBytes.SequenceEqual(SignatureConstantBytes)) { return false; } - if (stream.Length < currPosition + FieldLocations.FilenameLength) - { - return false; - } - // Already read the signature, so make the filename length field location relative to that stream.Seek(FieldLocations.FilenameLength - FieldLengths.Signature, SeekOrigin.Current); @@ -684,12 +679,11 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeFilenameLengthLocation..]); ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeExtraFieldLengthLocation..]); - if (stream.Length < stream.Position + filenameLength + extraFieldLength) - { - return false; - } - - stream.Seek(filenameLength + extraFieldLength, SeekOrigin.Current); + // Calculate absolute position of compressed data and seek there + // Using SeekOrigin.Begin ensures we end up at the correct position + // regardless of any edge cases during header parsing + long dataStart = stream.Position + filenameLength + extraFieldLength; + stream.Seek(dataStart, SeekOrigin.Begin); return true; } @@ -698,9 +692,8 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i public static bool TrySkipBlock(Stream stream) { Span blockBytes = stackalloc byte[FieldLengths.Signature]; - long currPosition = stream.Position; int bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); - if (!TrySkipBlockCore(stream, blockBytes, bytesRead, currPosition)) + if (!TrySkipBlockCore(stream, blockBytes, bytesRead)) { return false; } @@ -708,63 +701,6 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? aesExtraField) - { - aesExtraField = null; - BinaryReader reader = new BinaryReader(stream); - - // Read the first 4 bytes (local file header signature) - byte[] signatureBytes = reader.ReadBytes(4); - if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) - { - return false; // Not a valid local file header - } - - // Read fixed-size fields after signature - // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) - reader.ReadBytes(22); // Skip 22 bytes total - - ushort nameLength = reader.ReadUInt16(); - ushort extraLength = reader.ReadUInt16(); - - // Skip file name - stream.Seek(nameLength, SeekOrigin.Current); - - // Calculate end of extra fields - long extraEnd = stream.Position + extraLength; - - // Parse extra fields if present - if (extraLength > 0) - { - while (stream.Position < extraEnd) - { - ushort headerId = reader.ReadUInt16(); - ushort dataSize = reader.ReadUInt16(); - - if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - ushort vendorVersion = reader.ReadUInt16(); - reader.ReadBytes(2); // Skip vendor ID 'AE' - byte aesStrength = reader.ReadByte(); // 1, 2, or 3 - ushort compressionMethod = reader.ReadUInt16(); - - aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); - break; - } - else - { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field - } - } - } - - // Ensure we're positioned at the end of extra fields (where data begins) - stream.Seek(extraEnd, SeekOrigin.Begin); - - return true; - } } internal struct WinZipAesExtraField { From 16d8a279f2d2c16be5adb17d0c82381c8103f24e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 19 Jan 2026 12:45:19 +0100 Subject: [PATCH 31/83] fix refs and remove crc validation --- .../tests/ZipFile.Encryption.cs | 9 +- .../ref/System.IO.Compression.cs | 7 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 14 +- .../System/IO/Compression/ZipArchiveEntry.cs | 26 ++- .../IO/Compression/ZipCompressionMethod.cs | 7 +- .../System/IO/Compression/ZipCustomStreams.cs | 155 ------------------ 6 files changed, 24 insertions(+), 194 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 9aa43bbe8d617d..71bcfb47d03b73 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -886,8 +886,6 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.E #region CompressionMethod Property Tests for Encrypted Entries - #region CompressionMethod Property Tests for Encrypted Entries - [Theory] [MemberData(nameof(Get_Booleans_Data))] public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompressionMethod(bool async) @@ -925,10 +923,11 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression #endregion - #endregion + #region Zip64 Tests for Encrypted Entries [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnCI("Skipping large disk space test on CI machines.")] public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1025,6 +1024,7 @@ public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.Encr [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnCI("Skipping large disk space test on CI machines.")] public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1082,7 +1082,7 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn // Opening the entry in update mode requires decrypting into memory, // which should fail for entries larger than memorystream MaxValue (~2GB) - Assert.ThrowsAny(() => entry.Open(password)); + Assert.Throws(() => entry.Open(password)); } } finally @@ -1095,5 +1095,6 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn } } + #endregion } } 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 31bb46529441da..ed13e75e386017 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -127,11 +127,11 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } + public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.IO.Stream Open(FileAccess access) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string? password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte { @@ -153,7 +153,6 @@ public enum ZipCompressionMethod Stored = 0, Deflate = 8, Deflate64 = 9, - Aes = 99 } public sealed partial class ZLibCompressionOptions { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 2502b91c24fd1d..764eafd51e3c59 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -94,7 +94,7 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell } } - public async Task OpenAsync(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) + public async Task OpenAsync(string? password, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -370,7 +370,7 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && (ushort)_headerCompressionMethod == AesEncryptionMarker) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. @@ -522,8 +522,8 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); } - // Restore CompressionMethod to Aes for central directory - CompressionMethod = ZipCompressionMethod.Aes; + // Restore CompressionMethod - AesCompressionMethodValue is used directly when writing headers + CompressionMethod = savedMethod; } else { @@ -573,14 +573,11 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; EncryptionMethod savedEncryption = Encryption; - ZipCompressionMethod savedCompressionMethod = CompressionMethod; - // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear Encryption so WriteLocalFileHeaderAsync doesn't create a new + // For AES entries: clear Encryption so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { - CompressionMethod = ZipCompressionMethod.Aes; Encryption = EncryptionMethod.None; } @@ -589,7 +586,6 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // Restore original state _generalPurposeBitFlag = savedFlags; Encryption = savedEncryption; - CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) 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 2cbefbba894a77..fb9a768bf0eb46 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 @@ -55,7 +55,7 @@ public partial class ZipArchiveEntry // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] // For ZipCrypto: contains [key0][key1][key2] as 12 bytes private byte[]? _derivedEncryptionKeyMaterial; - + internal const ushort AesEncryptionMarker = 99; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) { @@ -746,7 +746,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u // For AES encryption, write compression method 99 (Aes) in the header // _headerCompressionMethod preserves the original value from the central directory - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)AesEncryptionMarker : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); @@ -929,7 +929,7 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && _headerCompressionMethod != ZipCompressionMethod.Aes; + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != AesEncryptionMarker; } private bool UseAesEncryption() @@ -954,7 +954,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); - bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; + bool isAesEncrypted = (ushort)_headerCompressionMethod == AesEncryptionMarker; if (!isAesEncrypted && IsZipCryptoEncrypted()) { @@ -1005,7 +1005,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) default: // We should not get here with Aes as CompressionMethod anymore // as it should have been replaced with the actual compression method - Debug.Assert(CompressionMethod != ZipCompressionMethod.Aes, + Debug.Assert((ushort)CompressionMethod != AesEncryptionMarker, "AES compression method should have been replaced with actual compression method"); // Fallback to stored if we somehow get here @@ -1041,12 +1041,6 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); - if (UseAesEncryption() && _aeVersion == 1) - { - // Wrap with CRC validator for AE-1 - return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); - } - return decompressedStream; } private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) @@ -1232,7 +1226,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && (ushort)_headerCompressionMethod == AesEncryptionMarker) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. @@ -1406,7 +1400,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; // Set compression method to 99 (AES indicator) in the header - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; aesExtraField = CreateAesExtraField(); @@ -1500,7 +1494,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); // For AES encryption, write compression method 99 (Aes) in the header - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)AesEncryptionMarker : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); @@ -1634,7 +1628,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) } // Restore CompressionMethod to Aes for central directory - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; } else { @@ -1690,7 +1684,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; Encryption = EncryptionMethod.None; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs index 4ad210316bd4f0..644b41714b04d9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs @@ -24,11 +24,6 @@ public enum ZipCompressionMethod /// /// The entry is compressed using the Deflate64 algorithm. /// - Deflate64 = 0x9, - - /// - /// The entry is encrypted using AES standard. - /// - Aes = 99 + Deflate64 = 0x9 } } 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 acff9c9aa60179..d0c0c55dccc5e4 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 @@ -694,159 +694,4 @@ public override async ValueTask DisposeAsync() } } - internal sealed class CrcValidatingReadStream : Stream - { - private readonly Stream _baseStream; - private uint _runningCrc; - private readonly uint _expectedCrc; - private long _totalBytesRead; - private readonly long _expectedLength; - private bool _isDisposed; - private bool _crcValidated; - - public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) - { - _baseStream = baseStream; - _expectedCrc = expectedCrc; - _expectedLength = expectedLength; - _runningCrc = 0; - _totalBytesRead = 0; - _crcValidated = false; - } - - public override bool CanRead => !_isDisposed && _baseStream.CanRead; - public override bool CanSeek => false; - public override bool CanWrite => false; - - public override long Length => _baseStream.Length; - - public override long Position - { - get => _baseStream.Position; - set => throw new NotSupportedException(SR.SeekingNotSupported); - } - - public override int Read(byte[] buffer, int offset, int count) - { - ThrowIfDisposed(); - ValidateBufferArguments(buffer, offset, count); - - int bytesRead = _baseStream.Read(buffer, offset, count); - ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); - - return bytesRead; - } - - public override int Read(Span buffer) - { - ThrowIfDisposed(); - - int bytesRead = _baseStream.Read(buffer); - ProcessBytesRead(buffer.Slice(0, bytesRead)); - - return bytesRead; - } - - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ThrowIfDisposed(); - ValidateBufferArguments(buffer, offset, count); - - int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); - - return bytesRead; - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - ThrowIfDisposed(); - - int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - ProcessBytesRead(buffer.Span.Slice(0, bytesRead)); - - return bytesRead; - } - - private void ProcessBytesRead(ReadOnlySpan data) - { - if (data.Length > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, data); - _totalBytesRead += data.Length; - - if (_totalBytesRead >= _expectedLength) - { - ValidateCrc(); - } - } - } - - private void ValidateCrc() - { - if (_crcValidated) - return; - - _crcValidated = true; - - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException(SR.CrcMismatch); - } - } - - public override void Write(byte[] buffer, int offset, int count) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); - } - - public override void Flush() - { - ThrowIfDisposed(); - } - - public override Task FlushAsync(CancellationToken cancellationToken) - { - ThrowIfDisposed(); - return Task.CompletedTask; - } - - public override long Seek(long offset, SeekOrigin origin) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.SeekingNotSupported); - } - - public override void SetLength(long value) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); - } - - 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); - } - } } From 3e5dd7967effbb29ffecb351f790f6d3ca2724e5 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 21 Jan 2026 11:11:08 +0100 Subject: [PATCH 32/83] update open overloads and extracttofile/directory calls --- .../ref/System.IO.Compression.ZipFile.cs | 15 +- .../System/IO/Compression/ZipFile.Extract.cs | 328 ++++++++++++++++++ ...FileExtensions.ZipArchive.Extract.Async.cs | 77 ++++ .../ZipFileExtensions.ZipArchive.Extract.cs | 11 + ...xtensions.ZipArchiveEntry.Extract.Async.cs | 17 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 7 +- .../tests/ZipFile.Encryption.cs | 238 +++++++++++++ .../ref/System.IO.Compression.cs | 6 +- .../src/Resources/Strings.resx | 12 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 28 +- .../System/IO/Compression/ZipArchiveEntry.cs | 29 +- 11 files changed, 755 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index b678c368782f50..ff4e40804392e6 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -22,12 +22,20 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, string password) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } @@ -53,15 +61,18 @@ public static partial class ZipFileExtensions public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs index cd2b78e64eb558..7da2b74ee2f91f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs @@ -188,6 +188,189 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti } } + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(sourceArchiveFileName); + + using ZipArchive archive = Open(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding); + archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + } + /// /// Extracts all the files from the zip archive stored in the specified stream and places them in the specified destination directory on the file system. /// @@ -328,5 +511,150 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding); archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles); } + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system and uses the specified character encoding for entry names. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, uses the specified character encoding for entry names, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(source); + if (!source.CanRead) + { + throw new ArgumentException(SR.UnreadableStream, nameof(source)); + } + + using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding); + archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index 8b42376813a2da..0aac6b396a03fc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -82,4 +82,81 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); } } + + /// + /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(source, destinationDirectoryName, overwriteFiles: false, password, cancellationToken); + + /// + /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destinationDirectoryName); + + foreach (ZipArchiveEntry entry in source.Entries) + { + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index ad9cfd4a6c2e63..c42472e65cc4d0 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -73,5 +73,16 @@ public static void ExtractToDirectory(this ZipArchive source, string destination entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); } } + + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destinationDirectoryName); + + foreach (ZipArchiveEntry entry in source.Entries) + { + entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); + } + } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index f097e93b36ba01..06b0f6531c310c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,10 +86,10 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string? password, CancellationToken cancellationToken = default) => + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string password, CancellationToken cancellationToken = default) => await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, CancellationToken cancellationToken = default) + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -124,4 +124,17 @@ internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, cancellationToken).ConfigureAwait(false); } } + + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) + { + // If it is a file: + // Create containing directory: + Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); + await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, password:password, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 8d252fc6a0b89b..1818c98d7acfc7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -160,14 +160,17 @@ private static bool ExtractRelativeToDirectoryCheckIfFile(ZipArchiveEntry source return true; // It is a file } - internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite) + internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string? password = null) { if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) { // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - source.ExtractToFile(fileDestinationPath, overwrite: overwrite); + if (!string.IsNullOrEmpty(password)) + source.ExtractToFile(fileDestinationPath, overwrite: overwrite, password: password); + else + source.ExtractToFile(fileDestinationPath, overwrite: overwrite); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 71bcfb47d03b73..6acb4d35cc51e7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1096,5 +1096,243 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn } #endregion + + #region ExtractToFile Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToFile_EncryptedEntry_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword123"; + string content = "Encrypted content for ExtractToFile test"; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + string destFile = GetTestFilePath(); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: false, password: password); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: false, password: password); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword123"; + string content = "Updated encrypted content"; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + string destFile = GetTestFilePath(); + // Create an existing file to be overwritten + File.WriteAllText(destFile, "Original content to be overwritten"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: true, password: password); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "CorrectPassword"; + var entries = new[] { ("encrypted.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + string destFile = GetTestFilePath(); + + if (async) + { + await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, overwrite: false, password: "WrongPassword")); + } + else + { + Assert.Throws(() => entry.ExtractToFile(destFile, overwrite: false, password: "WrongPassword")); + } + } + } + + #endregion + + #region ExtractToDirectory Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "SharedPassword"; + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("subfolder/file3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("subfolder/nested/file4.txt", "Content 4", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password); + } + else + { + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password); + } + } + + // Verify all files were extracted correctly + foreach (var (name, content, _, _) in entries) + { + string extractedPath = Path.Combine(tempDir.Path, name.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(extractedPath), $"File {name} should exist"); + Assert.Equal(content, File.ReadAllText(extractedPath)); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + // Create entries with different passwords + var entries = new[] + { + ("file1.txt", "Content 1", (string?)"Password1", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("file2.txt", "Content 2", (string?)"Password2", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("file3.txt", "Content 3", (string?)"Password3", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Using Password1 should fail for file2 and file3 + if (async) + { + await Assert.ThrowsAsync(() => + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, "Password1")); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, "Password1")); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword"; + var entries = new[] + { + ("file1.txt", "New Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "New Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + // Create existing files to be overwritten + File.WriteAllText(Path.Combine(tempDir.Path, "file1.txt"), "Old Content 1"); + File.WriteAllText(Path.Combine(tempDir.Path, "file2.txt"), "Old Content 2"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: true, password); + } + else + { + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: true, password); + } + } + + // Verify files were overwritten with new content + Assert.Equal("New Content 1", File.ReadAllText(Path.Combine(tempDir.Path, "file1.txt"))); + Assert.Equal("New Content 2", File.ReadAllText(Path.Combine(tempDir.Path, "file2.txt"))); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword"; + var entries = new[] + { + ("file1.txt", "New Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + // Create existing file that should not be overwritten + File.WriteAllText(Path.Combine(tempDir.Path, "file1.txt"), "Existing Content"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await Assert.ThrowsAsync(() => + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password)); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password)); + } + } + + // Verify existing file was not modified + Assert.Equal("Existing Content", File.ReadAllText(Path.Combine(tempDir.Path, "file1.txt"))); + } + + #endregion } } 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 ed13e75e386017..9f2e098bb43348 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -128,9 +128,11 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } - public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } + public System.IO.Stream Open(string password) { throw null; } + public System.IO.Stream Open(string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string? password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index bc35f91fe908d7..c418f2c05a806e 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -1,3 +1,4 @@ + @@ -70,11 +73,6 @@ - - - - - diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 7e5dd1d5cb1ecd..cb846e3b0d29d9 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -12,6 +12,7 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for WinZipAesStream encryption (AES-128, write-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class WinZipAes128EncryptionStreamConformanceTests : WinZipAesEncryptionStreamConformanceTests { protected override int KeySizeBits => 128; @@ -20,6 +21,7 @@ public sealed class WinZipAes128EncryptionStreamConformanceTests : WinZipAesEncr /// /// Conformance tests for WinZipAesStream encryption (AES-256, write-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class WinZipAes256EncryptionStreamConformanceTests : WinZipAesEncryptionStreamConformanceTests { protected override int KeySizeBits => 256; @@ -89,6 +91,7 @@ static WinZipAesEncryptionStreamConformanceTests() /// /// Conformance tests for WinZipAesStream decryption (AES-128, read-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class WinZipAes128DecryptionStreamConformanceTests : WinZipAesDecryptionStreamConformanceTests { protected override int KeySizeBits => 128; @@ -97,6 +100,7 @@ public sealed class WinZipAes128DecryptionStreamConformanceTests : WinZipAesDecr /// /// Conformance tests for WinZipAesStream decryption (AES-256, read-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class WinZipAes256DecryptionStreamConformanceTests : WinZipAesDecryptionStreamConformanceTests { protected override int KeySizeBits => 256; diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index b5ae985b6f7d11..34eebd4e2ac2b5 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -12,6 +12,7 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for ZipCryptoStream encryption (write-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class ZipCryptoEncryptionStreamConformanceTests : StandaloneStreamConformanceTests { private const string TestPassword = "test-password"; @@ -68,6 +69,7 @@ static ZipCryptoEncryptionStreamConformanceTests() /// /// Conformance tests for ZipCryptoStream decryption (read-only stream). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public sealed class ZipCryptoDecryptionStreamConformanceTests : StandaloneStreamConformanceTests { private const string TestPassword = "test-password"; diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 7750d404eaa5f0..2d169ba9ee53db 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -14,6 +14,7 @@ namespace System.IO.Compression.Tests /// Wrapped connected stream conformance tests for ZipCryptoStream. /// Tests encryption → decryption data flow through connected streams. ///
+ [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamConformanceTests { private const string TestPassword = "test-password"; From 405e7fac2db3c5f6d5053c17cac6b67d6914afcf Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 4 Feb 2026 11:13:13 +0100 Subject: [PATCH 43/83] address feedback --- .../System/IO/Compression/WinZipAesStream.cs | 54 ++++++++++--- .../System/IO/Compression/ZipCryptoStream.cs | 61 ++++++++------ .../tests/WinZipAesStreamConformanceTests.cs | 79 +++--------------- .../tests/ZipCryptoStreamConformanceTests.cs | 81 +++++-------------- .../ZipCryptoStreamWrappedConformanceTests.cs | 38 ++++----- 5 files changed, 125 insertions(+), 188 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index e0fece5949701f..f6956c18cde293 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -58,7 +58,9 @@ internal static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, in else { if (salt.Length != saltSize) + { throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); + } saltBytes = salt; } @@ -259,7 +261,9 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) + { return; + } // Finalize HMAC computation byte[] expectedAuth = _hmac.GetHashAndReset(); @@ -278,7 +282,9 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException(SR.WinZipAuthCodeMismatch); + { + throw new InvalidDataException(SR.UnexpectedEndOfStream); + } _authCodeValidated = true; } @@ -303,8 +309,10 @@ private void InitCipher() private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancellationToken) { - if (_headerWritten) return; - + if (_headerWritten) + { + return; + } Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); if (isAsync) @@ -409,7 +417,9 @@ private void IncrementCounter() for (int i = 0; i < 16; i++) { if (++_counterBlock[i] != 0) + { break; + } } } @@ -419,7 +429,9 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) + { return; + } byte[] authCode = _hmac.GetHashAndReset(); @@ -442,13 +454,17 @@ private void ThrowIfNotReadable() ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) + { throw new NotSupportedException(SR.ReadingNotSupported); + } } private int GetBytesToRead(int requestedCount) { if (_encryptedDataRemaining <= 0) + { return 0; + } return (int)Math.Min(requestedCount, _encryptedDataRemaining); } @@ -489,9 +505,10 @@ public override int Read(Span buffer) ValidateAuthCode(); } } - else + else if (_encryptedDataRemaining > 0) { - ValidateAuthCode(); + // Base stream returned 0 bytes but we expected more encrypted data - stream is truncated + throw new InvalidDataException(SR.UnexpectedEndOfStream); } return bytesRead; @@ -592,7 +609,9 @@ private void ThrowIfNotWritable() ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) + { throw new NotSupportedException(SR.WritingNotSupported); + } } public override void Write(byte[] buffer, int offset, int count) @@ -700,7 +719,10 @@ private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cance protected override void Dispose(bool disposing) { - if (_disposed) return; + if (_disposed) + { + return; + } if (disposing) { @@ -720,7 +742,10 @@ protected override void Dispose(bool disposing) // Write Auth Code WriteAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - if (_baseStream.CanWrite) _baseStream.Flush(); + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } } } finally @@ -729,7 +754,10 @@ protected override void Dispose(bool disposing) _aes.Dispose(); _hmac?.Dispose(); - if (!_leaveOpen) _baseStream.Dispose(); + if (!_leaveOpen) + { + _baseStream.Dispose(); + } } } @@ -739,7 +767,10 @@ protected override void Dispose(bool disposing) public override async ValueTask DisposeAsync() { - if (_disposed) return; + if (_disposed) + { + return; + } try { @@ -757,7 +788,10 @@ public override async ValueTask DisposeAsync() // Write Auth Code await WriteAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); - if (_baseStream.CanWrite) await _baseStream.FlushAsync().ConfigureAwait(false); + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync().ConfigureAwait(false); + } } } finally diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 5d19b57eb666fc..cd1a07dade6556 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -218,18 +218,18 @@ private ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) uint key2 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(8, 4)); byte[] hdr = new byte[12]; - try + int bytesRead; + + if (isAsync) { - if (isAsync) - { - await baseStream.ReadExactlyAsync(hdr, cancellationToken).ConfigureAwait(false); - } - else - { - baseStream.ReadExactly(hdr); - } + bytesRead = await baseStream.ReadAtLeastAsync(hdr, hdr.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); } - catch (EndOfStreamException) + else + { + bytesRead = baseStream.ReadAtLeast(hdr, hdr.Length, throwOnEndOfStream: false); + } + + if (bytesRead < hdr.Length) { throw new InvalidDataException(SR.TruncatedZipCryptoHeader); } @@ -292,14 +292,15 @@ public override int Read(byte[] buffer, int offset, int count) public override int Read(Span destination) { - if (!_encrypting) + if (_encrypting) { - int n = _base.Read(destination); - for (int i = 0; i < n; i++) - destination[i] = DecryptAndUpdateKeys(destination[i]); - return n; + throw new NotSupportedException(SR.ReadingNotSupported); } - throw new NotSupportedException(SR.ReadingNotSupported); + int n = _base.Read(destination); + for (int i = 0; i < n; i++) + destination[i] = DecryptAndUpdateKeys(destination[i]); + return n; + } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); @@ -343,10 +344,14 @@ protected override void Dispose(bool disposing) { // If encrypted empty entry (no payload written), still must emit 12-byte header: if (_encrypting && !_headerWritten) + { EnsureHeader(); + } if (!_leaveOpen) + { _base.Dispose(); + } } base.Dispose(disposing); } @@ -361,9 +366,13 @@ public override async ValueTask DisposeAsync() // If encrypted empty entry (no payload written), still must emit 12-byte header: if (_encrypting && !_headerWritten) + { await EnsureHeaderAsync(CancellationToken.None).ConfigureAwait(false); + } if (!_leaveOpen) + { await _base.DisposeAsync().ConfigureAwait(false); + } // Don't call base.DisposeAsync() as it would call Dispose() synchronously, // which could fail on async-only streams. We've already handled all cleanup. @@ -377,17 +386,19 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - if (!_encrypting) + if (_encrypting) { - cancellationToken.ThrowIfCancellationRequested(); - - int n = await _base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - Span span = buffer.Span; - for (int i = 0; i < n; i++) - span[i] = DecryptAndUpdateKeys(span[i]); - return n; + throw new NotSupportedException(SR.ReadingNotSupported); } - throw new NotSupportedException(SR.ReadingNotSupported); + + cancellationToken.ThrowIfCancellationRequested(); + int n = await _base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Span span = buffer.Span; + + for (int i = 0; i < n; i++) + span[i] = DecryptAndUpdateKeys(span[i]); + + return n; } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index cb846e3b0d29d9..8af2f9020205f7 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -10,27 +10,27 @@ namespace System.IO.Compression.Tests { /// - /// Conformance tests for WinZipAesStream encryption (AES-128, write-only stream). + /// Conformance tests for WinZipAesStream (AES-128). /// [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class WinZipAes128EncryptionStreamConformanceTests : WinZipAesEncryptionStreamConformanceTests + public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConformanceTests { protected override int KeySizeBits => 128; } /// - /// Conformance tests for WinZipAesStream encryption (AES-256, write-only stream). + /// Conformance tests for WinZipAesStream (AES-256). /// [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class WinZipAes256EncryptionStreamConformanceTests : WinZipAesEncryptionStreamConformanceTests + public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConformanceTests { protected override int KeySizeBits => 256; } /// - /// Base class for WinZipAesStream encryption conformance tests. + /// Base class for WinZipAesStream conformance tests. /// - public abstract class WinZipAesEncryptionStreamConformanceTests : StandaloneStreamConformanceTests + public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConformanceTests { private const string TestPassword = "test-password"; @@ -39,8 +39,9 @@ public abstract class WinZipAesEncryptionStreamConformanceTests : StandaloneStre private static readonly MethodInfo s_createMethod; protected abstract int KeySizeBits { get; } + protected int SaltSize => KeySizeBits / 16; - static WinZipAesEncryptionStreamConformanceTests() + static WinZipAesStreamConformanceTests() { var assembly = typeof(ZipArchive).Assembly; s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; @@ -60,11 +61,8 @@ static WinZipAesEncryptionStreamConformanceTests() protected override bool CanSeek => false; - protected override Task CreateReadOnlyStreamCore(byte[]? initialData) => - Task.FromResult(null); // Encryption stream is write-only - protected override Task CreateReadWriteStreamCore(byte[]? initialData) => - Task.FromResult(null); // Encryption stream is write-only + Task.FromResult(null); // WinZipAesStream is either read-only or write-only protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) { @@ -86,65 +84,6 @@ static WinZipAesEncryptionStreamConformanceTests() return Task.FromResult(encryptStream); } - } - - /// - /// Conformance tests for WinZipAesStream decryption (AES-128, read-only stream). - /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class WinZipAes128DecryptionStreamConformanceTests : WinZipAesDecryptionStreamConformanceTests - { - protected override int KeySizeBits => 128; - } - - /// - /// Conformance tests for WinZipAesStream decryption (AES-256, read-only stream). - /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class WinZipAes256DecryptionStreamConformanceTests : WinZipAesDecryptionStreamConformanceTests - { - protected override int KeySizeBits => 256; - } - - /// - /// Base class for WinZipAesStream decryption conformance tests. - /// - public abstract class WinZipAesDecryptionStreamConformanceTests : StandaloneStreamConformanceTests - { - private const string TestPassword = "test-password"; - - private static readonly Type s_winZipAesStreamType; - private static readonly MethodInfo s_createKeyMethod; - private static readonly MethodInfo s_createMethod; - - protected abstract int KeySizeBits { get; } - protected int SaltSize => KeySizeBits / 16; - - static WinZipAesDecryptionStreamConformanceTests() - { - var assembly = typeof(ZipArchive).Assembly; - s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; - - s_createKeyMethod = s_winZipAesStreamType.GetMethod("CreateKey", - BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(ReadOnlyMemory), typeof(byte[]), typeof(int) }, - null)!; - - s_createMethod = s_winZipAesStreamType.GetMethod("Create", - BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(Stream), typeof(byte[]), typeof(int), typeof(long), typeof(bool), typeof(bool) }, - null)!; - } - - protected override bool CanSeek => false; - - protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => - Task.FromResult(null); // Decryption stream is read-only - - protected override Task CreateReadWriteStreamCore(byte[]? initialData) => - Task.FromResult(null); // Decryption stream is read-only protected override Task CreateReadOnlyStreamCore(byte[]? initialData) { diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index 34eebd4e2ac2b5..0e30031c2395ab 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -4,16 +4,17 @@ using System.IO.Compression; using System.IO.Tests; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Xunit; namespace System.IO.Compression.Tests { /// - /// Conformance tests for ZipCryptoStream encryption (write-only stream). + /// Conformance tests for ZipCryptoStream. /// [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class ZipCryptoEncryptionStreamConformanceTests : StandaloneStreamConformanceTests + public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests { private const string TestPassword = "test-password"; private const ushort PasswordVerifier = 0x1234; @@ -21,8 +22,9 @@ public sealed class ZipCryptoEncryptionStreamConformanceTests : StandaloneStream private static readonly Type s_zipCryptoStreamType; private static readonly MethodInfo s_createKeyMethod; private static readonly MethodInfo s_createEncryptionMethod; + private static readonly MethodInfo s_createDecryptionMethod; - static ZipCryptoEncryptionStreamConformanceTests() + static ZipCryptoStreamConformanceTests() { var assembly = typeof(ZipArchive).Assembly; s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; @@ -38,14 +40,19 @@ static ZipCryptoEncryptionStreamConformanceTests() null, new[] { typeof(Stream), typeof(byte[]), typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, null)!; + + s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool) }, + null)!; + } protected override bool CanSeek => false; - protected override Task CreateReadOnlyStreamCore(byte[]? initialData) => - Task.FromResult(null); // Encryption stream is write-only protected override Task CreateReadWriteStreamCore(byte[]? initialData) => - Task.FromResult(null); // Encryption stream is write-only + Task.FromResult(null); // ZipCryptoStream is either read-only or write-only protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) { @@ -54,7 +61,7 @@ static ZipCryptoEncryptionStreamConformanceTests() var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { - ms, keyBytes, PasswordVerifier, true, null, false + ms, keyBytes, PasswordVerifier, true /* encrypting */, null /* crc32 */, false /* leaveOpen */ })!; if (initialData != null && initialData.Length > 0) @@ -64,76 +71,32 @@ static ZipCryptoEncryptionStreamConformanceTests() return Task.FromResult(encryptStream); } - } - - /// - /// Conformance tests for ZipCryptoStream decryption (read-only stream). - /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] - public sealed class ZipCryptoDecryptionStreamConformanceTests : StandaloneStreamConformanceTests - { - private const string TestPassword = "test-password"; - private const ushort PasswordVerifier = 0x1234; - // The check byte is the HIGH byte of the verifier (little-endian: byte 11 = 0x12) - private const byte ExpectedCheckByte = 0x12; - - private static readonly Type s_zipCryptoStreamType; - private static readonly MethodInfo s_createKeyMethod; - private static readonly MethodInfo s_createEncryptionMethod; - private static readonly MethodInfo s_createDecryptionMethod; - - static ZipCryptoDecryptionStreamConformanceTests() - { - var assembly = typeof(ZipArchive).Assembly; - s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - - s_createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", - BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(ReadOnlyMemory) }, - null)!; - - s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", - BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(Stream), typeof(byte[]), typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, - null)!; - - s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", - BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool) }, - null)!; - } - - protected override bool CanSeek => false; - - protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => - Task.FromResult(null); // Decryption stream is read-only - - protected override Task CreateReadWriteStreamCore(byte[]? initialData) => - Task.FromResult(null); // Decryption stream is read-only protected override Task CreateReadOnlyStreamCore(byte[]? initialData) { byte[] plaintext = initialData ?? Array.Empty(); byte[] keyBytes = (byte[])s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + // The check byte is the HIGH byte of the password verifier (little-endian format) + byte expectedCheckByte = (byte)(PasswordVerifier >> 8); + // Encrypt data first using var encryptedMs = new MemoryStream(); using (var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { - encryptedMs, keyBytes, PasswordVerifier, true, null, true + encryptedMs, keyBytes, PasswordVerifier, true /* encrypting */, null /* crc32 */, true /* leaveOpen */ })!) { encryptStream.Write(plaintext); } + byte[] encryptedData = encryptedMs.ToArray(); + // Create decryption stream over the encrypted data - var ms = new MemoryStream(encryptedMs.ToArray()); + var ms = new MemoryStream(encryptedData); var decryptStream = (Stream)s_createDecryptionMethod.Invoke(null, new object[] { - ms, keyBytes, ExpectedCheckByte, false, false + ms, keyBytes, expectedCheckByte, false /* encrypting */, false /* leaveOpen */ })!; return Task.FromResult(decryptStream); diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 2d169ba9ee53db..9a8178387e751e 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -24,6 +24,7 @@ public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamCon private static readonly MethodInfo s_createKeyMethod; private static readonly MethodInfo s_createEncryptionMethod; private static readonly MethodInfo s_createDecryptionMethod; + private static readonly MethodInfo s_createAsyncMethod; static ZipCryptoStreamWrappedConformanceTests() { @@ -47,13 +48,17 @@ static ZipCryptoStreamWrappedConformanceTests() null, new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool) }, null)!; + + s_createAsyncMethod = s_zipCryptoStreamType.GetMethod("CreateAsync", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, + null); } // ZipCryptoStream doesn't support seeking protected override bool CanSeek => false; - // The encryption header is written lazily, so flush is required - protected override bool FlushRequiredToWriteData => true; protected override bool FlushGuaranteesAllDataWritten => true; // ZipCrypto uses streaming cipher - blocks on zero byte reads @@ -108,33 +113,18 @@ private static async Task CreateDecryptStreamAsync(Stream baseStream, by var assembly = typeof(ZipArchive).Assembly; var zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - // Try to use CreateAsync first for proper async support - // Signature: CreateAsync(Stream, byte[], byte, bool encrypting, CancellationToken, bool leaveOpen) - var createAsyncMethod = zipCryptoStreamType.GetMethod("CreateAsync", - BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, - null, - new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, - null); - if (createAsyncMethod != null) + // CreateAsync returns Task, await it and get the result + var task = (Task)s_createAsyncMethod.Invoke(null, new object[] { - // CreateAsync returns Task, await it and get the result - var task = (Task)createAsyncMethod.Invoke(null, new object[] - { baseStream, keyBytes, expectedCheckByte, false /* encrypting */, CancellationToken.None, leaveOpen - })!; - await task.ConfigureAwait(false); + })!; + await task.ConfigureAwait(false); - // Get the Result property from the completed task - var resultProperty = task.GetType().GetProperty("Result")!; - return (Stream)resultProperty.GetValue(task)!; - } + // Get the Result property from the completed task + var resultProperty = task.GetType().GetProperty("Result")!; + return (Stream)resultProperty.GetValue(task)!; - // Fall back to sync method if async not available - return (Stream)s_createDecryptionMethod.Invoke(null, new object[] - { - baseStream, keyBytes, expectedCheckByte, false /* encrypting */, leaveOpen - })!; } } } From 6369631b4eadf012588444e408a8e452ea5b7591 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 5 Feb 2026 11:37:20 +0100 Subject: [PATCH 44/83] throw when using unsupported aes algorithm on browser --- .../tests/ZipFile.Encryption.cs | 105 +++++++++++++++++- .../src/Resources/Strings.resx | 3 + .../System/IO/Compression/WinZipAesStream.cs | 6 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 10 ++ .../System/IO/Compression/ZipArchiveEntry.cs | 19 ++++ 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 533c43204a4119..f97f5b5a331fb1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -27,6 +27,7 @@ public static IEnumerable EncryptionMethodAndBoolTestData() [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Encryption_SingleEntry_RoundTrip(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -49,6 +50,7 @@ public async Task Encryption_SingleEntry_RoundTrip(EncryptionMethod encryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -74,6 +76,7 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(EncryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -101,6 +104,7 @@ public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(EncryptionMethod e [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Encryption_Combinations_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -126,6 +130,7 @@ public async Task Encryption_Combinations_RoundTrip(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Encryption_LargeFile_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -169,6 +174,7 @@ public async Task Encryption_LargeFile_RoundTrip(bool async) } [Fact] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public void WrongPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); @@ -184,11 +190,11 @@ public void WrongPassword_Throws_InvalidDataException() } [Fact] - public void Negative_MissingPassword_Throws_InvalidDataException() + public void MissingPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.OpenRead(archivePath)) @@ -200,7 +206,7 @@ public void Negative_MissingPassword_Throws_InvalidDataException() [Theory] [MemberData(nameof(Get_Booleans_Data))] - public async Task Negative_OpeningPlainEntryWithPassword_Throws(bool async) + public async Task OpeningPlainEntryWithPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); var entries = new[] { ("plain.txt", "content", (string?)null, (EncryptionMethod?)null) }; @@ -216,6 +222,7 @@ public async Task Negative_OpeningPlainEntryWithPassword_Throws(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); @@ -301,6 +308,7 @@ private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -354,6 +362,7 @@ public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(EncryptionMethod enc [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -391,6 +400,7 @@ public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(EncryptionMet [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) { string archivePath = GetTempArchivePath(); @@ -434,6 +444,7 @@ public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) { string archivePath = GetTempArchivePath(); @@ -484,6 +495,7 @@ public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) { string archivePath = GetTempArchivePath(); @@ -548,6 +560,7 @@ public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -582,6 +595,7 @@ public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(EncryptionMet } [Fact] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public void UpdateMode_EncryptedEntry_WrongPassword_Throws() { string archivePath = GetTempArchivePath(); @@ -602,7 +616,7 @@ public void UpdateMode_EncryptedEntry_NoPassword_Throws() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) @@ -616,6 +630,7 @@ public void UpdateMode_EncryptedEntry_NoPassword_Throws() [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) { string archivePath = GetTempArchivePath(); @@ -682,6 +697,7 @@ public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) { string archivePath = GetTempArchivePath(); @@ -732,6 +748,7 @@ public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) { string archivePath = GetTempArchivePath(); @@ -782,6 +799,7 @@ public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) { string archivePath = GetTempArchivePath(); @@ -833,6 +851,7 @@ public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -888,6 +907,7 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(EncryptionMethod [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompressionMethod(bool async) { string archivePath = GetTempArchivePath(); @@ -1082,6 +1102,7 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(EncryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1113,6 +1134,7 @@ public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encrypti [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1145,6 +1167,7 @@ public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(EncryptionMetho [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); @@ -1175,6 +1198,7 @@ public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Success(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1213,6 +1237,7 @@ public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Succe [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(bool async) { string archivePath = GetTempArchivePath(); @@ -1245,6 +1270,7 @@ await Assert.ThrowsAsync(() => [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1281,6 +1307,7 @@ public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(EncryptionMe [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Throws(bool async) { string archivePath = GetTempArchivePath(); @@ -1324,7 +1351,7 @@ public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) { string archivePath = GetTempArchivePath(); string password = "secret"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -1347,6 +1374,7 @@ public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) { string archivePath = GetTempArchivePath(); @@ -1374,6 +1402,7 @@ public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); @@ -1422,6 +1451,7 @@ public static IEnumerable CreateEntryFromFile_Encrypted_TestData() [Theory] [MemberData(nameof(CreateEntryFromFile_Encrypted_TestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] public async Task CreateEntryFromFile_Encrypted_RoundTrip(EncryptionMethod encryptionMethod, bool useCompression, bool async) { string archivePath = GetTempArchivePath(); @@ -1465,6 +1495,71 @@ public async Task CreateEntryFromFile_Encrypted_RoundTrip(EncryptionMethod encry #endregion + #region Browser Platform Tests + + public static IEnumerable AesEncryptionMethodAndBoolTestData() + { + foreach (var method in new[] + { + EncryptionMethod.Aes128, + EncryptionMethod.Aes192, + EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + [PlatformSpecific(TestPlatforms.Browser)] + public async Task Browser_ZipCrypto_Encryption_Works(bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "ZipCrypto Content on Browser"; + string password = "password123"; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using (Stream s = entry.Open(password, EncryptionMethod.ZipCrypto)) + using (StreamWriter w = new StreamWriter(s, Encoding.UTF8)) + { + if (async) + await w.WriteAsync(content); + else + w.Write(content); + } + } + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, content, password, async); + } + } + + [Theory] + [MemberData(nameof(AesEncryptionMethodAndBoolTestData))] + [PlatformSpecific(TestPlatforms.Browser)] + public async Task Browser_AesEncryption_Throws_PlatformNotSupportedException(EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string password = "password123"; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + Assert.Throws(() => entry.Open(password, encryptionMethod)); + } + } + + #endregion } } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index c418f2c05a806e..7223195b8c7773 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -345,4 +345,7 @@ Encryption Method should not be specified in Update Mode + + WinZip encryption is not supported on browser + \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index f6956c18cde293..d81b8ad717083f 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -10,6 +11,7 @@ namespace System.IO.Compression { + [UnsupportedOSPlatform("browser")] internal sealed class WinZipAesStream : Stream { private const int BlockSize = 16; // AES block size in bytes @@ -217,9 +219,7 @@ private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); -#pragma warning restore CA1416 _aes.Mode = CipherMode.ECB; _aes.Padding = PaddingMode.None; @@ -249,9 +249,7 @@ private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, } } -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); -#pragma warning restore CA5350 InitCipher(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index a8e14769cdbdc7..0dbe3cc991ad85 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -476,6 +476,11 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre } else if (isAesEncrypted) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + int keySizeBits = GetAesKeySizeBits(Encryption); // Read salt from stream to derive keys @@ -681,6 +686,11 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can } else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) { + + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } // For AES, we need to: // 1. Write header with CompressionMethod = Aes (99) // 2. Compress data with actual compression (Deflate/Stored) 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 089637e8053bfd..37d87d4f40fa98 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 @@ -1050,6 +1050,11 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor } else if (isAesEncrypted) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + int keySizeBits = GetAesKeySizeBits(Encryption); // Read salt from stream to derive keys @@ -1168,6 +1173,11 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet } else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + if (string.IsNullOrEmpty(password)) throw new InvalidOperationException(SR.NoPasswordProvided); @@ -1261,6 +1271,10 @@ private void SetupEncryptionKeyMaterial(string password) } else if (UseAesEncryption()) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } // Generate new salt and derive key material for AES // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(Encryption); @@ -1673,6 +1687,11 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) } else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + // For AES, we need to: // 1. Write header with CompressionMethod = Aes (99) // 2. Compress data with actual compression (Deflate/Stored) From 91aa400bc0cc3ec5dc69694e294ebf6207d5553b Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 9 Feb 2026 14:24:12 +0100 Subject: [PATCH 45/83] address copilot feedback --- .../ref/System.IO.Compression.cs | 12 +- .../System/IO/Compression/EncryptionMethod.cs | 2 +- .../System/IO/Compression/WinZipAesStream.cs | 10 +- .../System/IO/Compression/ZipArchiveEntry.cs | 5 +- .../System/IO/Compression/ZipCryptoStream.cs | 5 +- .../tests/System.IO.Compression.Tests.csproj | 1 - .../tests/WinZipAesStreamConformanceTests.cs | 5 +- .../WinZipAesStreamWrappedConformanceTests.cs | 125 ------------------ 8 files changed, 22 insertions(+), 143 deletions(-) delete mode 100644 src/libraries/System.IO.Compression/tests/WinZipAesStreamWrappedConformanceTests.cs 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 00ec03e7d20613..a3e2c6d7405c1b 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -54,13 +54,13 @@ public override void Write(System.ReadOnlySpan buffer) { } public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override void WriteByte(byte value) { } } - public enum EncryptionMethod : byte + public enum EncryptionMethod { - None = (byte)0, - ZipCrypto = (byte)1, - Aes128 = (byte)2, - Aes192 = (byte)3, - Aes256 = (byte)4, + None = 0, + ZipCrypto = 1, + Aes128 = 2, + Aes192 = 3, + Aes256 = 4, } public partial class GZipStream : System.IO.Stream { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs index d1d1a4afdffd5a..b76684ab6cc156 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs @@ -6,7 +6,7 @@ namespace System.IO.Compression /// /// Specifies the encryption method used to encrypt an entry in a zip archive. /// - public enum EncryptionMethod : byte + public enum EncryptionMethod { /// /// No Encryption is applied to the entry. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index d81b8ad717083f..27b1d4030128c2 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -838,11 +838,11 @@ public override async Task FlushAsync(CancellationToken cancellationToken) } } - // Finally flush base stream to ensure encrypted data is written - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } + //// Finally flush base stream to ensure encrypted data is written + //if (_baseStream.CanWrite) + //{ + // await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + //} } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); 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 7ab7e09bd4376b..6647cf25df3ea1 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 @@ -419,6 +419,7 @@ public Stream Open() /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. + /// The requested access is not compatible with the archive's open mode. public Stream Open(string password) { ThrowIfInvalidArchive(); @@ -431,7 +432,7 @@ public Stream Open(string password) } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: - throw new Exception(SR.EntriesInCreateMode); + throw new InvalidOperationException(SR.EntriesInCreateMode); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -456,7 +457,7 @@ public Stream Open(string password, EncryptionMethod encryptionMethod) switch (_archive.Mode) { case ZipArchiveMode.Read: - throw new Exception(SR.EncryptionReadMode); + throw new InvalidOperationException(SR.EncryptionReadMode); case ZipArchiveMode.Create: return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index cd1a07dade6556..49407e3ac3bb49 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using System.Diagnostics; +using System.Text; namespace System.IO.Compression { @@ -125,7 +126,7 @@ public static byte[] CreateKey(ReadOnlyMemory password) uint key2 = 878082192; // ZipCrypto uses raw bytes; ASCII is the most interoperable - var bytes = password.Span.ToArray(); + var bytes = Encoding.ASCII.GetBytes(password.ToArray()); foreach (byte b in bytes) { UpdateKeys(ref key0, ref key1, ref key2, b); @@ -374,6 +375,8 @@ public override async ValueTask DisposeAsync() await _base.DisposeAsync().ConfigureAwait(false); } + GC.SuppressFinalize(this); + // Don't call base.DisposeAsync() as it would call Dispose() synchronously, // which could fail on async-only streams. We've already handled all cleanup. } 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 8b074ffb298a71..9f37c9bd28dc35 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 @@ -19,7 +19,6 @@ - diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 8af2f9020205f7..583e07ee5bf71e 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -4,6 +4,7 @@ using System.IO.Compression; using System.IO.Tests; using System.Reflection; +using System.Runtime.Versioning; using System.Threading.Tasks; using Xunit; @@ -12,7 +13,7 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for WinZipAesStream (AES-128). /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [UnsupportedOSPlatform("browser")] public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConformanceTests { protected override int KeySizeBits => 128; @@ -21,7 +22,7 @@ public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConforma /// /// Conformance tests for WinZipAesStream (AES-256). /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [UnsupportedOSPlatform("browser")] public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConformanceTests { protected override int KeySizeBits => 256; diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamWrappedConformanceTests.cs deleted file mode 100644 index 2b81b44279ff71..00000000000000 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamWrappedConformanceTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -//// Licensed to the .NET Foundation under one or more agreements. -//// The .NET Foundation licenses this file to you under the MIT license. - -//using System.IO.Tests; -//using System.Reflection; -//using System.Threading; -//using System.Threading.Tasks; -//using Microsoft.DotNet.XUnitExtensions; - -//namespace System.IO.Compression.Tests -//{ -// /// -// /// Wrapped connected stream conformance tests for WinZipAesStream (AES-128). -// /// Tests encryption → decryption data flow through connected streams. -// /// -// /// Note: WinZipAesStream has significant limitations for streaming: -// /// 1. It requires knowing the total encrypted size upfront for decryption -// /// 2. It has a 10-byte HMAC trailer written only on dispose -// /// -// /// These tests use a fixed-size approach where we know the test data size. -// /// -// public class WinZipAes128StreamWrappedConformanceTests : WinZipAesStreamWrappedConformanceTestsBase -// { -// protected override int KeySizeBits => 128; -// } - -// /// -// /// Wrapped connected stream conformance tests for WinZipAesStream (AES-256). -// /// -// public class WinZipAes256StreamWrappedConformanceTests : WinZipAesStreamWrappedConformanceTestsBase -// { -// protected override int KeySizeBits => 256; -// } - -// /// -// /// Base class for WinZipAesStream wrapped connected stream conformance tests. -// /// -// /// WinZipAesStream is fundamentally incompatible with the connected stream -// /// conformance test model because: -// /// -// /// 1. Decryption requires knowing total stream size BEFORE creating the stream -// /// (to distinguish encrypted data from the 10-byte HMAC trailer) -// /// 2. Connected stream tests write variable amounts of data dynamically -// /// 3. The HMAC trailer is only written when the encryption stream is disposed -// /// -// /// This is an architectural limitation of the WinZip AES format, not a bug. -// /// The format was designed for file-based archives where sizes are known upfront, -// /// not for streaming scenarios. -// /// -// /// TODO: Should I just delete these tests altogether since they can't run? -// /// -// /// -// public abstract class WinZipAesStreamWrappedConformanceTestsBase : WrappingConnectedStreamConformanceTests -// { -// private const string TestPassword = "test-password"; - -// private static readonly Type s_winZipAesStreamType; -// private static readonly MethodInfo s_createKeyMethod; -// private static readonly MethodInfo s_createMethod; -// private static readonly MethodInfo s_getSaltSizeMethod; - -// protected abstract int KeySizeBits { get; } -// protected int SaltSize => KeySizeBits / 16; -// protected int KeySizeBytes => KeySizeBits / 8; -// // Header = salt + 2-byte verifier, Trailer = 10-byte HMAC -// protected int HeaderSize => SaltSize + 2; -// protected const int HmacSize = 10; - -// static WinZipAesStreamWrappedConformanceTestsBase() -// { -// var assembly = typeof(ZipArchive).Assembly; -// s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; - -// s_createKeyMethod = s_winZipAesStreamType.GetMethod("CreateKey", -// BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, -// null, -// new[] { typeof(ReadOnlyMemory), typeof(byte[]), typeof(int) }, -// null)!; - -// s_createMethod = s_winZipAesStreamType.GetMethod("Create", -// BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, -// null, -// new[] { typeof(Stream), typeof(byte[]), typeof(int), typeof(long), typeof(bool), typeof(bool) }, -// null)!; - -// s_getSaltSizeMethod = s_winZipAesStreamType.GetMethod("GetSaltSize", -// BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, -// null, -// new[] { typeof(int) }, -// null)!; -// } - -// // WinZipAesStream doesn't support seeking -// protected override bool CanSeek => false; - -// // The encryption header is written lazily, so flush is required -// protected override bool FlushRequiredToWriteData => true; -// protected override bool FlushGuaranteesAllDataWritten => true; - -// // AES-CTR mode blocks on zero byte reads when data is buffered -// protected override bool BlocksOnZeroByteReads => true; - -// // No concurrent exception type -// protected override Type UnsupportedConcurrentExceptionType => null!; - -// private const string SkipReason = "WinZipAesStream requires knowing total encrypted stream size " + -// "upfront for decryption (to locate HMAC trailer). This is incompatible with connected " + -// "stream tests where data size is not known ahead of time."; - -// /// -// /// WinZipAesStream requires knowing total stream size for decryption. -// /// This is incompatible with generic connected stream tests that don't know -// /// the data size upfront. -// /// -// protected override Task CreateConnectedStreamsAsync() -// { -// throw new SkipTestException(SkipReason); -// } - -// protected override Task CreateWrappedConnectedStreamsAsync(StreamPair wrapped, bool leaveOpen = false) -// { -// throw new SkipTestException(SkipReason); -// } -// } -//} From 369a85ec921cb4b4241cf3c8bdba977160de64b0 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 11 Feb 2026 13:38:09 +0100 Subject: [PATCH 46/83] add missing overloads and fix copilot feedback --- .../ref/System.IO.Compression.ZipFile.cs | 8 + .../IO/Compression/ZipFile.Extract.Async.cs | 362 ++++++++++++++++++ .../ZipFileExtensions.ZipArchive.Create.cs | 2 +- .../System/IO/Compression/WinZipAesStream.cs | 7 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 2 +- .../System/IO/Compression/ZipArchiveEntry.cs | 1 - .../tests/WinZipAesStreamConformanceTests.cs | 2 + .../ZipCryptoStreamWrappedConformanceTests.cs | 4 - 8 files changed, 378 insertions(+), 10 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index e99e1798f5f00f..5d7668edddd71b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -36,12 +36,20 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.IO.Compression.ZipArchive Open(string archiveFileName, System.IO.Compression.ZipArchiveMode mode) { throw null; } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index 90baf60eac4706..51dcedaf1a2055 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -205,6 +205,206 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s } } + /// + /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// An asynchronous operation is cancelled. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); + + /// + /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// An asynchronous operation is cancelled. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); + + /// + /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// An asynchronous operation is cancelled. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); + + /// + /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// An asynchronous operation is cancelled. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(sourceArchiveFileName); + + ZipArchive archive = await OpenAsync(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding, cancellationToken).ConfigureAwait(false); + await using (archive) + { + await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + } + } + /// /// Asynchronously extracts all the files from the zip archive stored in the specified stream and places them in the specified destination directory on the file system. /// @@ -362,4 +562,166 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); } } + + /// + /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); + + /// + /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); + + /// + /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system and uses the specified character encoding for entry names. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); + + /// + /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, uses the specified character encoding for entry names, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(source); + if (!source.CanRead) + { + throw new ArgumentException(SR.UnreadableStream, nameof(source)); + } + + ZipArchive archive = await ZipArchive.CreateAsync(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); + await using (archive) + { + await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 91d841a0016038..7dc447d45dc5f3 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -137,7 +137,7 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio string password, EncryptionMethod encryption) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false); using (fs) { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 27b1d4030128c2..ad6548459e2c47 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -281,7 +281,7 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) { - throw new InvalidDataException(SR.UnexpectedEndOfStream); + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } _authCodeValidated = true; @@ -548,9 +548,10 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } } - else + else if (_encryptedDataRemaining > 0) { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + // Base stream returned 0 bytes but we expected more encrypted data - stream is truncated + throw new InvalidDataException(SR.UnexpectedEndOfStream); } return bytesRead; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 298259753863b4..36404ea4fd5bf0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -215,7 +215,7 @@ public async Task OpenAsync(string password, CancellationToken cancellat } return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); case ZipArchiveMode.Create: - throw new Exception(SR.EncryptionNotSpecified); + throw new InvalidOperationException(SR.EncryptionNotSpecified); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); 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 6647cf25df3ea1..4e6aae9fc1af9c 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 @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 583e07ee5bf71e..7bf06eaeedce98 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -13,6 +13,7 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for WinZipAesStream (AES-128). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] [UnsupportedOSPlatform("browser")] public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConformanceTests { @@ -22,6 +23,7 @@ public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConforma /// /// Conformance tests for WinZipAesStream (AES-256). /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] [UnsupportedOSPlatform("browser")] public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConformanceTests { diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 9a8178387e751e..af7098605f3507 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -110,10 +110,6 @@ protected override async Task CreateWrappedConnectedStreamsAsync(Str private static async Task CreateDecryptStreamAsync(Stream baseStream, byte[] keyBytes, byte expectedCheckByte, bool leaveOpen) { - var assembly = typeof(ZipArchive).Assembly; - var zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - - // CreateAsync returns Task, await it and get the result var task = (Task)s_createAsyncMethod.Invoke(null, new object[] { From 9a075ed2fa470251d4f2dccfcd0840b0f0264748 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 13 Feb 2026 11:30:58 +0100 Subject: [PATCH 47/83] fix conflict --- .../ZipFileExtensions.ZipArchive.Extract.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index cd4da869257f5a..a73250114c2db9 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -74,6 +74,66 @@ public static void ExtractToDirectory(this ZipArchive source, string destination } } + /// + /// Extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, string password) => + ExtractToDirectory(source, destinationDirectoryName, overwriteFiles: false, password: password); + + /// + /// Extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) { ArgumentNullException.ThrowIfNull(source); From c220b1b437efd462c21b3d45089067ecbe75ce97 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 13 Feb 2026 13:14:11 +0100 Subject: [PATCH 48/83] fix errors --- .../ref/System.IO.Compression.ZipFile.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 6 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 66 ++++++++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 5d7668edddd71b..cb9b420a71a16f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -59,7 +59,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti public static System.IO.Compression.ZipArchive OpenRead(string archiveFileName) { throw null; } public static System.Threading.Tasks.Task OpenReadAsync(string archiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] + [System.ComponentModel.EditorBrowsableAttribute(1)] public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 9543e5ee975f0e..f7883a2aa65615 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -71,7 +71,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { cancellationToken.ThrowIfCancellationRequested(); - ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). @@ -125,7 +125,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { cancellationToken.ThrowIfCancellationRequested(); - ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); FileStream fs = new FileStream(destinationFileName, fileStreamOptions); await using (fs) @@ -166,7 +166,7 @@ internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, password:password, cancellationToken).ConfigureAwait(false); + await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, password: password, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 415a8abe46878a..a958f3bcf55b8b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -62,7 +62,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// True to indicate overwrite. public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite) { - ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). @@ -105,7 +105,69 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination } } - private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, bool useAsync, out FileStreamOptions fileStreamOptions) + /// + /// Creates a file on the file system with the entry's contents decrypted using the specified password. + /// The last write time of the file is set to the entry's last write time. + /// This method does not allow overwriting of an existing file with the same name. + /// + /// The zip archive entry to extract a file from. + /// The name of the file that will hold the contents of the entry. + /// The password used to decrypt the encrypted entry. + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, string password) => + ExtractToFile(source, destinationFileName, overwrite: false, password: password); + + /// + /// Creates a file on the file system with the entry's contents decrypted using the specified password. + /// The last write time of the file is set to the entry's last write time. + /// This method allows overwriting of an existing file with the same name. + /// + /// The zip archive entry to extract a file from. + /// The name of the file that will hold the contents of the entry. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entry. + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) + { + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + + // When overwriting, extract to a temporary file first to avoid corrupting the destination file + // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). + string extractPath = destinationFileName; + string? tempPath = null; + + if (overwrite && File.Exists(destinationFileName)) + { + tempPath = Path.GetTempFileName(); + extractPath = tempPath; + } + + try + { + using (FileStream fs = new FileStream(extractPath, fileStreamOptions)) + { + using (Stream es = !string.IsNullOrEmpty(password) ? source.Open(password) : source.Open()) + es.CopyTo(fs); + } + + // Move the temporary file to the destination only after successful extraction + if (tempPath is not null) + { + File.Move(tempPath, destinationFileName, overwrite: true); + } + + ExtractToFileFinalize(source, destinationFileName); + } + catch + { + // Clean up the temporary file if extraction failed + if (tempPath is not null && File.Exists(tempPath)) + { + try { File.Delete(tempPath); } catch { } + } + throw; + } + } + + private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, out FileStreamOptions fileStreamOptions) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationFileName); From 733afd45ab9648f64dc13c5e708e6c7b2da5d351 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 16 Feb 2026 12:19:43 +0100 Subject: [PATCH 49/83] address copilot feedback --- .../ref/System.IO.Compression.ZipFile.cs | 3 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 4 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 9 ++-- .../tests/ZipFile.Encryption.cs | 12 ++--- .../src/Resources/Strings.resx | 4 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 21 ++++++++- .../System/IO/Compression/ZipArchiveEntry.cs | 45 ++++++++++++++++--- .../src/System/IO/Compression/ZipBlocks.cs | 5 +++ .../ZipCryptoStreamWrappedConformanceTests.cs | 3 -- 9 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index cb9b420a71a16f..211d0640e62acf 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -59,7 +59,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti public static System.IO.Compression.ZipArchive OpenRead(string archiveFileName) { throw null; } public static System.Threading.Tasks.Task OpenReadAsync(string archiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } - [System.ComponentModel.EditorBrowsableAttribute(1)] + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } @@ -73,6 +73,7 @@ public static partial class ZipFileExtensions public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password) { } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index f7883a2aa65615..25b48df53dc754 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -71,7 +71,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { cancellationToken.ThrowIfCancellationRequested(); - ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). @@ -125,7 +125,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { cancellationToken.ThrowIfCancellationRequested(); - ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); FileStream fs = new FileStream(destinationFileName, fileStreamOptions); await using (fs) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index a958f3bcf55b8b..6fb11e0bc32bd4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -62,7 +62,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// True to indicate overwrite. public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite) { - ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). @@ -127,7 +127,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// The password used to decrypt the encrypted entry. public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { - ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). @@ -167,7 +167,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination } } - private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, out FileStreamOptions fileStreamOptions) + private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, bool useAsync, out FileStreamOptions fileStreamOptions) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationFileName); @@ -177,7 +177,8 @@ private static void ExtractToFileInitialize(ZipArchiveEntry source, string desti Access = FileAccess.Write, Mode = overwrite ? FileMode.Create : FileMode.CreateNew, Share = FileShare.None, - BufferSize = ZipFile.FileStreamBufferSize + BufferSize = ZipFile.FileStreamBufferSize, + Options = useAsync ? FileOptions.Asynchronous : FileOptions.None }; const UnixFileMode OwnershipPermissions = diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index f97f5b5a331fb1..01fa16f5293e5e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1388,8 +1388,8 @@ public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) // Read access in create mode throws await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password")); // Encryption without password throws - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, null!, EncryptionMethod.Aes256)); - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, "", EncryptionMethod.Aes256)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, null!, EncryptionMethod.Aes256)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, "", EncryptionMethod.Aes256)); } else { @@ -1418,15 +1418,15 @@ public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bo if (async) { - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "")); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "")); await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, null!)); await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "")); } else { - Assert.ThrowsAny(() => entry.Open(FileAccess.Read, null!)); - Assert.ThrowsAny(() => entry.Open(FileAccess.Read, "")); + Assert.ThrowsAny(() => entry.Open(FileAccess.Read, null!)); + Assert.ThrowsAny(() => entry.Open(FileAccess.Read, "")); Assert.ThrowsAny(() => entry.Open(FileAccess.ReadWrite, null!)); Assert.ThrowsAny(() => entry.Open(FileAccess.ReadWrite, "")); } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index 7223195b8c7773..0d01327148f3a7 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -324,8 +324,8 @@ A password is required for encrypted entries. - - No password was provided for encrypting this entry + + Empty password was provided for encrypting this entry Invalid AES strength value. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 36404ea4fd5bf0..e316ca472b262a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -1,11 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers.Binary; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using static System.IO.Compression.ZipLocalFileHeader; namespace System.IO.Compression; @@ -118,6 +116,10 @@ public async Task OpenAsync(FileAccess access, string password, Cancella { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); @@ -179,7 +181,14 @@ public Task OpenAsync(FileAccess access, string password, EncryptionMeth ThrowIfInvalidArchive(); if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) + { throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + } + + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } switch (_archive.Mode) { @@ -205,6 +214,10 @@ public async Task OpenAsync(string password, CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } switch (_archive.Mode) { @@ -231,6 +244,10 @@ public async Task OpenAsync(string password, EncryptionMethod encryption { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } switch (_archive.Mode) { 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 4e6aae9fc1af9c..55aab705857291 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 @@ -419,9 +419,14 @@ public Stream Open() /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. /// The requested access is not compatible with the archive's open mode. + /// The password provided is null. public Stream Open(string password) { ThrowIfInvalidArchive(); + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } switch (_archive.Mode) { case ZipArchiveMode.Read: @@ -453,6 +458,10 @@ public Stream Open(string password) public Stream Open(string password, EncryptionMethod encryptionMethod) { ThrowIfInvalidArchive(); + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } switch (_archive.Mode) { case ZipArchiveMode.Read: @@ -522,7 +531,10 @@ public Stream Open(FileAccess access) public Stream Open(FileAccess access, string password) { ThrowIfInvalidArchive(); - + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); @@ -1151,11 +1163,14 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet // Build the stream stack with encryption if needed Stream targetStream = _archive.ArchiveStream; + Stream? encryptionStream = null; if (encryptionMethod == EncryptionMethod.ZipCrypto) { if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException(SR.NoPasswordProvided); + { + throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + } Encryption = encryptionMethod; @@ -1169,6 +1184,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet encrypting: true, crc32: null, leaveOpen: true); + encryptionStream = targetStream; } else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { @@ -1178,7 +1194,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet } if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException(SR.NoPasswordProvided); + throw new InvalidOperationException(SR.EmptyPassword); Encryption = encryptionMethod; @@ -1194,6 +1210,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet totalStreamSize: -1, encrypting: true, leaveOpen: true); + encryptionStream = targetStream; } bool isAesEncryption = encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128; @@ -1210,7 +1227,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet }, streamForPosition: encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod, encryptionStream); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } @@ -2096,10 +2113,11 @@ private sealed class DirectToArchiveWriterStream : Stream private bool _usedZip64inLH; private bool _canWrite; private readonly EncryptionMethod _encryption; + private readonly Stream? _encryptionStream; // makes the assumption that somewhere down the line, crcSizeStream is eventually writing directly to the archive // this class calls other functions on ZipArchiveEntry that write directly to the archive - public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, EncryptionMethod encryptionMethod = EncryptionMethod.None) + public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, EncryptionMethod encryptionMethod = EncryptionMethod.None, Stream? encryptionStream = null) { _position = 0; _crcSizeStream = crcSizeStream; @@ -2109,6 +2127,7 @@ public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, Zip _usedZip64inLH = false; _canWrite = true; _encryption = encryptionMethod; + _encryptionStream = encryptionStream; } public override long Length @@ -2271,8 +2290,14 @@ protected override void Dispose(bool disposing) { _crcSizeStream.Dispose(); // now we have size/crc info + // If no data was written through CheckSumAndSizeWriteStream, its lazy _baseStream + // (DeflateStream wrapping the encryption stream) was never created, so the encryption + // stream would be orphaned. Dispose it explicitly to finalize encryption + // (e.g., write the ZipCrypto 12-byte header or AES salt/verifier/HMAC). if (!_everWritten) { + _encryptionStream?.Dispose(); + // write local header, no data, so we use stored _entry.WriteLocalFileHeader(isEmptyFile: true, forceWrite: true); } @@ -2299,8 +2324,17 @@ public override async ValueTask DisposeAsync() { await _crcSizeStream.DisposeAsync().ConfigureAwait(false); // now we have size/crc info + // If no data was written through CheckSumAndSizeWriteStream, its lazy _baseStream + // (DeflateStream wrapping the encryption stream) was never created, so the encryption + // stream would be orphaned. Dispose it explicitly to finalize encryption + // (e.g., write the ZipCrypto 12-byte header or AES salt/verifier/HMAC). if (!_everWritten) { + if (_encryptionStream is not null) + { + await _encryptionStream.DisposeAsync().ConfigureAwait(false); + } + // write local header, no data, so we use stored await _entry.WriteLocalFileHeaderAsync(isEmptyFile: true, forceWrite: true, cancellationToken: default).ConfigureAwait(false); } @@ -2321,7 +2355,6 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } - [Flags] internal enum BitFlagValues : ushort { 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 47b34e5a4ce755..472a1a69c389b7 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 @@ -679,6 +679,11 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeFilenameLengthLocation..]); ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeExtraFieldLengthLocation..]); + if (stream.Length < stream.Position + filenameLength + extraFieldLength) + { + return false; + } + // Calculate absolute position of compressed data and seek there // Using SeekOrigin.Begin ensures we end up at the correct position // regardless of any edge cases during header parsing diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index af7098605f3507..5e51ae5135bbae 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -93,9 +93,6 @@ protected override async Task CreateWrappedConnectedStreamsAsync(Str await encryptStream.WriteAsync(new byte[] { 0 }, 0, 1).ConfigureAwait(false); // Trigger header write await encryptStream.FlushAsync().ConfigureAwait(false); - // Wait briefly for data to flow through connected streams - await Task.Delay(10).ConfigureAwait(false); - // Now create the decryption stream (read-only) - wraps stream2 // This will read and validate the 12-byte header // Use async factory method to support AsyncOnlyStream wrappers From da37424062fc4239bb2ef358b0583327fcb4b17f Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 3 Mar 2026 12:33:47 +0200 Subject: [PATCH 50/83] refactor winzipaesstream key derivation material and address comments --- .../System/IO/Compression/WinZipAesStream.cs | 309 +++++++++--------- .../IO/Compression/ZipArchiveEntry.Async.cs | 17 +- .../System/IO/Compression/ZipArchiveEntry.cs | 59 +--- 3 files changed, 175 insertions(+), 210 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index ad6548459e2c47..42592d4f7008e5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -1,8 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Diagnostics; -using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; @@ -11,48 +11,75 @@ namespace System.IO.Compression { - [UnsupportedOSPlatform("browser")] - internal sealed class WinZipAesStream : Stream + /// + /// Represents the parsed components of WinZip AES key material. + /// The key material layout is: [salt][encryption key][HMAC key][password verifier (2 bytes)]. + /// + internal readonly struct WinZipAesKeyMaterial { - private const int BlockSize = 16; // AES block size in bytes - private const int KeystreamBufferSize = 4096; // Pre-generate 4KB of keystream (256 blocks) - private readonly Stream _baseStream; - private readonly bool _encrypting; - private readonly int _keySizeBits; - private readonly Aes _aes; - private ICryptoTransform? _aesEncryptor; - private IncrementalHash? _hmac; - private readonly byte[] _counterBlock = new byte[BlockSize]; - private byte[]? _key; - private byte[]? _hmacKey; - private byte[]? _salt; - private byte[]? _passwordVerifier; - private bool _headerWritten; - private bool _disposed; - private bool _authCodeValidated; - private readonly long _totalStreamSize; - private readonly bool _leaveOpen; - private readonly long _encryptedDataSize; - private long _encryptedDataRemaining; - private readonly byte[] _partialBlock = new byte[BlockSize]; - private int _partialBlockBytes; + public byte[] Salt { get; } + public byte[] EncryptionKey { get; } + public byte[] HmacKey { get; } + public byte[] PasswordVerifier { get; } + public int KeySizeBits { get; } + public int SaltSize { get; } - // Pre-generated keystream buffer for efficiency - private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; - private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, byte[] passwordVerifier, int keySizeBits) + { + Salt = salt; + EncryptionKey = encryptionKey; + HmacKey = hmacKey; + PasswordVerifier = passwordVerifier; + KeySizeBits = keySizeBits; + SaltSize = GetSaltSize(keySizeBits); + } - internal static int GetSaltSize(int keySizeBits) => keySizeBits / 16; + /// + /// Parses raw key material bytes into their individual components. + /// Validates that the input length matches the expected layout for the given key size. + /// + internal static WinZipAesKeyMaterial Parse(byte[] keyMaterial, int keySizeBits) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int expectedSize = checked(saltSize + keySizeBytes + keySizeBytes + 2); - //A byte array containing salt + derived key material - internal static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, int keySizeBits) + if (keyMaterial.Length != expectedSize) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } + + int offset = 0; + + byte[] salt = new byte[saltSize]; + Array.Copy(keyMaterial, offset, salt, 0, saltSize); + offset += saltSize; + + byte[] encryptionKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); + offset += keySizeBytes; + + byte[] hmacKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); + offset += keySizeBytes; + + byte[] passwordVerifier = new byte[2]; + Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); + + return new WinZipAesKeyMaterial(salt, encryptionKey, hmacKey, passwordVerifier, keySizeBits); + } + + /// + /// Derives key material from a password and optional salt using PBKDF2-SHA1. + /// + internal static WinZipAesKeyMaterial Create(ReadOnlyMemory password, byte[]? salt, int keySizeBits) { int saltSize = GetSaltSize(keySizeBits); int keySizeBytes = keySizeBits / 8; - int totalKeySize = keySizeBytes + keySizeBytes + 2; // encryption key + HMAC key + verifier + int totalKeySize = checked(keySizeBytes + keySizeBytes + 2); - // Generate or validate salt byte[] saltBytes; - if (salt == null) + if (salt is null) { saltBytes = new byte[saltSize]; RandomNumberGenerator.Fill(saltBytes); @@ -66,16 +93,16 @@ internal static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, in saltBytes = salt; } - // Derive keys using PBKDF2 int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); - Span passwordBytes = stackalloc byte[maxPasswordByteCount]; - int actualByteCount = Encoding.UTF8.GetBytes(password.Span, passwordBytes); - Span passwordSpan = passwordBytes[..actualByteCount]; - + byte[] rentedPasswordBytes = System.Buffers.ArrayPool.Shared.Rent(maxPasswordByteCount); + // totalKeySize is at most 66 bytes (AES-256: 32 + 32 + 2), safe for stackalloc Span derivedKey = stackalloc byte[totalKeySize]; try { + int actualByteCount = Encoding.UTF8.GetBytes(password.Span, rentedPasswordBytes); + Span passwordSpan = rentedPasswordBytes.AsSpan(0, actualByteCount); + Rfc2898DeriveBytes.Pbkdf2( passwordSpan, saltBytes, @@ -83,97 +110,95 @@ internal static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, in 1000, HashAlgorithmName.SHA1); - // Format: [salt][encryption key][HMAC key][password verifier] - byte[] result = new byte[saltSize + totalKeySize]; - saltBytes.CopyTo(result, 0); - derivedKey.CopyTo(result.AsSpan(saltSize)); + byte[] rawMaterial = new byte[checked(saltSize + totalKeySize)]; + saltBytes.CopyTo(rawMaterial, 0); + derivedKey.CopyTo(rawMaterial.AsSpan(saltSize)); - return result; + return Parse(rawMaterial, keySizeBits); } finally { - CryptographicOperations.ZeroMemory(passwordBytes); + CryptographicOperations.ZeroMemory(rentedPasswordBytes); CryptographicOperations.ZeroMemory(derivedKey); + System.Buffers.ArrayPool.Shared.Return(rentedPasswordBytes); } } - // Parses persisted key material into its components. - private static void ParseKeyMaterial(byte[] keyMaterial, int keySizeBits, - out byte[] salt, out byte[] encryptionKey, out byte[] hmacKey, out byte[] passwordVerifier) - { - int saltSize = GetSaltSize(keySizeBits); - int keySizeBytes = keySizeBits / 8; - int expectedSize = saltSize + keySizeBytes + keySizeBytes + 2; + internal static int GetSaltSize(int keySizeBits) => keySizeBits / 16; + } - Debug.Assert(keyMaterial.Length == expectedSize, "Key material length does not match expected size."); - int offset = 0; + [UnsupportedOSPlatform("browser")] + internal sealed class WinZipAesStream : Stream + { + private const int BlockSize = 16; // AES block size in bytes + private const int KeystreamBufferSize = 4096; // Pre-generate 4KB of keystream (256 blocks) - salt = new byte[saltSize]; - Array.Copy(keyMaterial, offset, salt, 0, saltSize); - offset += saltSize; + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly Aes _aes; + private ICryptoTransform? _aesEncryptor; + private IncrementalHash? _hmac; + private UInt128 _counter = 1; + private readonly byte[] _salt; + private readonly byte[] _passwordVerifier; + private bool _headerWritten; + private bool _disposed; + private bool _authCodeValidated; + private readonly long _totalStreamSize; + private readonly bool _leaveOpen; + private readonly long _encryptedDataSize; + private long _encryptedDataRemaining; + private readonly byte[] _partialBlock = new byte[BlockSize]; + private int _partialBlockBytes; - encryptionKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); - offset += keySizeBytes; + // Pre-generated keystream buffer for efficiency + private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; + private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation - hmacKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); - offset += keySizeBytes; + internal static int GetSaltSize(int keySizeBits) => WinZipAesKeyMaterial.GetSaltSize(keySizeBits); - passwordVerifier = new byte[2]; - Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); - } + /// + /// Derives key material from a password and optional salt. + /// + internal static WinZipAesKeyMaterial CreateKey(ReadOnlyMemory password, byte[]? salt, int keySizeBits) + => WinZipAesKeyMaterial.Create(password, salt, keySizeBits); /// - /// Creates a WinZipAesStream for decryption. Reads and validates the header synchronously. + /// Creates a WinZipAesStream synchronously. Reads and validates the header for decryption. /// - internal static WinZipAesStream Create(Stream baseStream, byte[] keyMaterial, int keySizeBits, long totalStreamSize, bool encrypting, bool leaveOpen = false) + internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - ArgumentNullException.ThrowIfNull(keyMaterial); if (!encrypting) { - // Read and validate header before creating the stream - ReadAndValidateHeaderCore(isAsync: false, baseStream, keyMaterial, keySizeBits, CancellationToken.None).GetAwaiter().GetResult(); + ReadAndValidateHeaderCore(isAsync: false, baseStream, keyMaterial, CancellationToken.None).GetAwaiter().GetResult(); } - return new WinZipAesStream(baseStream, keyMaterial, keySizeBits, totalStreamSize, encrypting, leaveOpen); + return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); } /// - /// Creates a WinZipAesStream for decryption. Reads and validates the header asynchronously. + /// Creates a WinZipAesStream asynchronously. Reads and validates the header for decryption. /// - internal static async Task CreateAsync(Stream baseStream, byte[] keyMaterial, int keySizeBits, long totalStreamSize, bool encrypting, bool leaveOpen = false, CancellationToken cancellationToken = default) + internal static async Task CreateAsync(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(baseStream); - ArgumentNullException.ThrowIfNull(keyMaterial); if (!encrypting) { - // Read and validate header before creating the stream - await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyMaterial, keySizeBits, cancellationToken).ConfigureAwait(false); - + await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyMaterial, cancellationToken).ConfigureAwait(false); } - return new WinZipAesStream(baseStream, keyMaterial, keySizeBits, totalStreamSize, encrypting, leaveOpen); + return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); } /// - /// Reads and validates the WinZip AES header. + /// Reads and validates the WinZip AES header (salt + password verifier) from the stream. /// - private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, byte[] keyMaterial, int keySizeBits, CancellationToken cancellationToken) + private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, WinZipAesKeyMaterial keyMaterial, CancellationToken cancellationToken) { - int saltSize = GetSaltSize(keySizeBits); - int keySizeBytes = keySizeBits / 8; - - // Parse the expected salt and password verifier from key material - byte[] expectedSalt = new byte[saltSize]; - Array.Copy(keyMaterial, 0, expectedSalt, 0, saltSize); - - byte[] expectedVerifier = new byte[2]; - int verifierOffset = saltSize + keySizeBytes + keySizeBytes; // After salt + encryption key + HMAC key - Array.Copy(keyMaterial, verifierOffset, expectedVerifier, 0, 2); + int saltSize = keyMaterial.SaltSize; // Read salt from stream byte[] fileSalt = new byte[saltSize]; @@ -197,25 +222,29 @@ private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStr baseStream.ReadExactly(verifier); } - // Verify the salt matches - Debug.Assert(fileSalt.AsSpan().SequenceEqual(expectedSalt), "Salt mismatch - key material does not match this entry."); + // Verify the salt matches — use constant-time comparison because the salt is + // derived from secret key material and a timing side-channel could leak information. + if (!CryptographicOperations.FixedTimeEquals(fileSalt, keyMaterial.Salt)) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } - // Verify the password verifier - if (!verifier.AsSpan().SequenceEqual(expectedVerifier)) + // Verify the password verifier using constant-time comparison to prevent + // timing attacks that could distinguish a wrong password from a corrupt archive. + if (!CryptographicOperations.FixedTimeEquals(verifier, keyMaterial.PasswordVerifier)) { throw new InvalidDataException(SR.InvalidPassword); } } /// - /// Private constructor - used in Create/CreateAsync - /// For decryption, header must already be validated before calling this constructor. + /// Private constructor — used by Create/CreateAsync. + /// For decryption, the header must already be validated before calling this constructor. /// - private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, long totalStreamSize, bool encrypting, bool leaveOpen) + private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen) { _baseStream = baseStream; _encrypting = encrypting; - _keySizeBits = keySizeBits; _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; @@ -223,11 +252,8 @@ private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, _aes.Mode = CipherMode.ECB; _aes.Padding = PaddingMode.None; - Array.Clear(_counterBlock, 0, 16); - _counterBlock[0] = 1; - - // Parse the persisted key material - ParseKeyMaterial(keyMaterial, keySizeBits, out _salt!, out _key!, out _hmacKey!, out _passwordVerifier!); + _salt = keyMaterial.Salt; + _passwordVerifier = keyMaterial.PasswordVerifier; if (encrypting) { @@ -236,8 +262,7 @@ private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, } else { - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier + int headerSize = checked(keyMaterial.SaltSize + 2); // Salt + Password Verifier const int hmacSize = 10; // 10-byte HMAC _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; @@ -249,8 +274,9 @@ private WinZipAesStream(Stream baseStream, byte[] keyMaterial, int keySizeBits, } } - _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); - InitCipher(); + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, keyMaterial.HmacKey); + _aes.Key = keyMaterial.EncryptionKey; + _aesEncryptor = _aes.CreateEncryptor(); } private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) @@ -279,7 +305,7 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can } // Compare the first 10 bytes of the expected hash - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + if (!CryptographicOperations.FixedTimeEquals(storedAuth, expectedAuth.AsSpan(0, 10))) { throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } @@ -297,21 +323,12 @@ private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); } - private void InitCipher() - { - Debug.Assert(_key is not null, "_key is not null"); - - _aes.Key = _key!; - _aesEncryptor = _aes.CreateEncryptor(); - } - private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (_headerWritten) { return; } - Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); if (isAsync) { @@ -324,10 +341,6 @@ private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancella _baseStream.Write(_passwordVerifier); } - // output to debug log - Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); - Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); - _headerWritten = true; } @@ -364,20 +377,16 @@ private void ProcessBlock(Span buffer) Span dataSpan = buffer.Slice(processed, bytesToProcess); ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); - // For encryption: XOR first, then HMAC the ciphertext if (_encrypting) { - // XOR the data with the keystream to create ciphertext + // For encryption: XOR first, then HMAC the ciphertext XorBytes(dataSpan, keystreamSpan); - // HMAC is computed on the ciphertext (after XOR) _hmac.AppendData(dataSpan); } - // For decryption: HMAC first (on ciphertext), then XOR else { - // HMAC is computed on the ciphertext (before XOR) + // For decryption: HMAC first (on ciphertext), then XOR _hmac.AppendData(dataSpan); - // XOR the ciphertext with the keystream to recover plaintext XorBytes(dataSpan, keystreamSpan); } @@ -385,15 +394,19 @@ private void ProcessBlock(Span buffer) processed += bytesToProcess; } } + private void GenerateKeystreamBuffer() { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); - // Generate KeystreamBufferSize bytes of keystream (256 blocks of 16 bytes each) + byte[] counterBlock = new byte[BlockSize]; + + // Generate KeystreamBufferSize bytes of keystream (256 blocks of 16 bytes each) for (int i = 0; i < KeystreamBufferSize; i += BlockSize) { - _aesEncryptor.TransformBlock(_counterBlock, 0, BlockSize, _keystreamBuffer, i); - IncrementCounter(); + BinaryPrimitives.WriteUInt128LittleEndian(counterBlock, _counter); + _aesEncryptor.TransformBlock(counterBlock, 0, BlockSize, _keystreamBuffer, i); + _counter++; } _keystreamOffset = 0; @@ -409,18 +422,6 @@ private static void XorBytes(Span dest, ReadOnlySpan src) } } - private void IncrementCounter() - { - // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer - for (int i = 0; i < 16; i++) - { - if (++_counterBlock[i] != 0) - { - break; - } - } - } - private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); @@ -443,10 +444,9 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel _baseStream.Write(authCode, 0, 10); } - Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); - _authCodeValidated = true; } + private void ThrowIfNotReadable() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -799,7 +799,10 @@ public override async ValueTask DisposeAsync() _aes.Dispose(); _hmac?.Dispose(); - if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); + if (!_leaveOpen) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + } } _disposed = true; @@ -830,20 +833,10 @@ public override async Task FlushAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - if (_encrypting) + if (_baseStream.CanWrite) { - // First flush base stream to ensure header is written - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } - - //// Finally flush base stream to ensure encrypted data is written - //if (_baseStream.CanWrite) - //{ - // await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - //} } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index e316ca472b262a..078825b7741d33 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -325,7 +325,8 @@ private async Task GetUncompressedDataAsync(CancellationToken canc _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; - _derivedEncryptionKeyMaterial = null; + _derivedZipCryptoKeyMaterial = null; + _derivedAesKeyMaterial = null; throw; } } @@ -503,12 +504,11 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre compressedStream.Seek(-saltSize, SeekOrigin.Current); // Derive key material from the provided password - byte[] keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); return await WinZipAesStream.CreateAsync( baseStream: compressedStream, keyMaterial: keyMaterial, - keySizeBits: keySizeBits, totalStreamSize: _compressedSize, encrypting: false, leaveOpen: false, @@ -666,7 +666,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial != null) { // Write local file header first (with encryption flag set) // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header @@ -679,7 +679,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can var encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, - keyBytes: _derivedEncryptionKeyMaterial, + keyBytes: _derivedZipCryptoKeyMaterial, passwordVerifierLow2Bytes: verifierLow2Bytes, encrypting: true, crc32: null, @@ -707,7 +707,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; } - else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) + else if (UseAesEncryption() && _derivedAesKeyMaterial != null) { if (OperatingSystem.IsBrowser()) @@ -733,11 +733,12 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can var encryptionStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, - keyMaterial: _derivedEncryptionKeyMaterial, - keySizeBits: keySizeBits, + keyMaterial: _derivedAesKeyMaterial.Value, totalStreamSize: -1, encrypting: true, leaveOpen: true); + + await using (encryptionStream.ConfigureAwait(false)) { // Only compress/write if there's data 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 55aab705857291..4942ff76e27569 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 @@ -50,10 +50,10 @@ public partial class ZipArchiveEntry private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; - // Cached derived key material for encrypted entries to allow updating in place - // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] - // For ZipCrypto: contains [key0][key1][key2] as 12 bytes - private byte[]? _derivedEncryptionKeyMaterial; + // Cached derived key material for encrypted entries to allow updating in place. + // Only one of these is set at a time, depending on the encryption method. + private byte[]? _derivedZipCryptoKeyMaterial; + private WinZipAesKeyMaterial? _derivedAesKeyMaterial; internal const ushort WinZipAesMethod = 99; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -677,7 +677,8 @@ private MemoryStream GetUncompressedData(string? password = null) _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; - _derivedEncryptionKeyMaterial = null; + _derivedZipCryptoKeyMaterial = null; + _derivedAesKeyMaterial = null; throw; } } @@ -1077,12 +1078,11 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor compressedStream.Seek(-saltSize, SeekOrigin.Current); // Derive key material from the provided password - byte[] keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); return WinZipAesStream.Create( baseStream: compressedStream, keyMaterial: keyMaterial, - keySizeBits: keySizeBits, totalStreamSize: _compressedSize, encrypting: false); } @@ -1090,6 +1090,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor // Not encrypted - return as-is return compressedStream; } + private Stream GetDataDecompressor(Stream compressedStreamToRead) { Stream? uncompressedStream; @@ -1201,12 +1202,11 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet int keySizeBits = GetAesKeySizeBits(encryptionMethod); // Derive key material from password with new random salt - byte[] keyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); targetStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, keyMaterial: keyMaterial, - keySizeBits: keySizeBits, totalStreamSize: -1, encrypting: true, leaveOpen: true); @@ -1297,7 +1297,7 @@ private void SetupEncryptionKeyMaterial(string password) // Derive and save key material for re-encryption if (IsZipCryptoEncrypted()) { - _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); Encryption = EncryptionMethod.ZipCrypto; } else if (UseAesEncryption()) @@ -1309,7 +1309,7 @@ private void SetupEncryptionKeyMaterial(string password) // Generate new salt and derive key material for AES // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(Encryption); - _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); // Encryption is already set from constructor (parsed from central directory AES extra field) } @@ -1689,81 +1689,58 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial is not null) { - // Write local file header first (with encryption flag set) - // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); - // Record position before encryption data long startPosition = _archive.ArchiveStream.Position; ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); using (var encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, - keyBytes: _derivedEncryptionKeyMaterial, + keyBytes: _derivedZipCryptoKeyMaterial, passwordVerifierLow2Bytes: verifierLow2Bytes, encrypting: true, crc32: null, leaveOpen: true)) { - // Use GetDataCompressor which handles CRC calculation and compression using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); _storedUncompressedData.CopyTo(crcStream); } - // CRC, uncompressed size are now set by GetDataCompressor callback - // For empty files, ZipCryptoStream.Dispose() will write the 12-byte header } - // Calculate compressed size AFTER ZipCryptoStream is disposed - // (includes 12-byte encryption header + compressed data) _compressedSize = _archive.ArchiveStream.Position - startPosition; - // Write data descriptor since we used streaming mode WriteDataDescriptor(); _storedUncompressedData.Dispose(); _storedUncompressedData = null; } - else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) + else if (UseAesEncryption() && _derivedAesKeyMaterial is not null) { if (OperatingSystem.IsBrowser()) { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } - // For AES, we need to: - // 1. Write header with CompressionMethod = Aes (99) - // 2. Compress data with actual compression (Deflate/Stored) - // 3. Keep CompressionMethod = Aes for central directory - - // WriteLocalFileHeader will set CompressionMethod = Aes WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); - // Record position before encryption data long startPosition = _archive.ArchiveStream.Position; - int keySizeBits = GetAesKeySizeBits(Encryption); - - // Determine the actual compression method to use - // The AES extra field stores the real compression method bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; using (var encryptionStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, - keyMaterial: _derivedEncryptionKeyMaterial, - keySizeBits: keySizeBits, + keyMaterial: _derivedAesKeyMaterial.Value, totalStreamSize: -1, encrypting: true, leaveOpen: true)) { - // Only compress/write if there's data if (_storedUncompressedData.Length > 0) { - // Temporarily set CompressionMethod for GetDataCompressor ZipCompressionMethod savedMethod = CompressionMethod; CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; @@ -1773,23 +1750,17 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _storedUncompressedData.CopyTo(crcStream); } - // Restore CompressionMethod to Aes for central directory CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; } else { - // Empty file: CRC is 0, uncompressed size is 0 _crc32 = 0; _uncompressedSize = 0; } - // WinZipAesStream.Dispose() writes salt + verifier + HMAC even for empty files } - // Calculate compressed size AFTER WinZipAesStream is disposed - // (includes salt + password verifier + encrypted data + HMAC) _compressedSize = _archive.ArchiveStream.Position - startPosition; - // Write data descriptor since we used streaming mode WriteDataDescriptor(); _storedUncompressedData.Dispose(); From f954bd90380396f98c05de4d1fe5b6d97a487fef Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 5 Mar 2026 13:02:09 +0200 Subject: [PATCH 51/83] add fuzzing to encryption streams --- .../libraries/fuzzing/deploy-to-onefuzz.yml | 16 ++ .../Fuzzers/WinZipAesStreamFuzzer.cs | 137 ++++++++++++++++++ .../Fuzzers/ZipCryptoStreamFuzzer.cs | 104 +++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs create mode 100644 src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs diff --git a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml index 830d35c8130e19..def3ce1cc34e07 100644 --- a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml +++ b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml @@ -184,6 +184,14 @@ extends: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Send Utf8JsonWriterFuzzer to OneFuzz + - task: onefuzz-task@0 + inputs: + onefuzzOSes: 'Windows' + env: + onefuzzDropDirectory: $(fuzzerProject)/deployment/WinZipAesStreamFuzzer + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Send WinZipAesStreamFuzzer to OneFuzz + - task: onefuzz-task@0 inputs: onefuzzOSes: 'Windows' @@ -191,4 +199,12 @@ extends: onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipArchiveFuzzer SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Send ZipArchiveFuzzer to OneFuzz + + - task: onefuzz-task@0 + inputs: + onefuzzOSes: 'Windows' + env: + onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipCryptoStreamFuzzer + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Send ZipCryptoStreamFuzzer to OneFuzz # ONEFUZZ_TASK_WORKAROUND_END diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs new file mode 100644 index 00000000000000..87b7b31caa2d06 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace DotnetFuzzing.Fuzzers; + +[UnsupportedOSPlatform("browser")] +internal sealed class WinZipAesStreamFuzzer : IFuzzer +{ + public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; + public string[] TargetCoreLibPrefixes => []; + public string Corpus => "winzipaesstream"; + + // AES-256 key size in bits; salt size = keySizeBits / 16 = 16 bytes. + private const int KeySizeBits = 256; + +#pragma warning disable IL2026 // RequiresUnreferencedCode + private static readonly Type _winZipAesStreamType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; + private static readonly Type _winZipAesKeyMaterialType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; +#pragma warning restore IL2026 + +#pragma warning disable IL2077 // dynamic access to non-public members + private static readonly MethodInfo _createKeyMethod = _winZipAesStreamType.GetMethod( + "CreateKey", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo _createMethod = _winZipAesStreamType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(Stream), _winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool)], + modifiers: null)!; + + // The salt and password verifier properties are needed to prepend a valid header + // so the stream's ReadAndValidateHeaderCore succeeds and decryption logic is reached. + private static readonly PropertyInfo _saltProp = _winZipAesKeyMaterialType.GetProperty( + "Salt", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly PropertyInfo _verifierProp = _winZipAesKeyMaterialType.GetProperty( + "PasswordVerifier", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; +#pragma warning restore IL2077 + + // Pre-derive key material once with a fixed password and no salt so the fuzzer focuses + // on the stream's decryption/HMAC logic rather than key derivation. + private static readonly object s_keyMaterial = _createKeyMethod.Invoke( + obj: null, + parameters: ["fuzz".AsMemory(), null, KeySizeBits])!; + + // Cache the salt and password verifier bytes for prepending to the fuzz input. + private static readonly byte[] s_salt = (byte[])_saltProp.GetValue(s_keyMaterial)!; + private static readonly byte[] s_verifier = (byte[])_verifierProp.GetValue(s_keyMaterial)!; + + // Minimum fuzz input: at least 1 byte of encrypted data beyond the header. + // The header (salt + verifier) is prepended by CreateStream, so the fuzz input + // only needs to supply encrypted data + the 10-byte auth code. + private const int MinInputLength = 11; // 1 byte data + 10 bytes HMAC + + public void FuzzTarget(ReadOnlySpan bytes) + { + if (bytes.Length < MinInputLength) + { + return; + } + + TestStream(CopyToRentedArray(bytes), bytes.Length, async: false).GetAwaiter().GetResult(); + TestStream(CopyToRentedArray(bytes), bytes.Length, async: true).GetAwaiter().GetResult(); + } + + private static Stream CreateStream(byte[] bytes, int length) + { + // Prepend the valid salt + password verifier so ReadAndValidateHeaderCore passes, + // allowing the fuzzer to exercise the CTR decryption and HMAC validation paths. + int headerSize = s_salt.Length + s_verifier.Length; + int totalSize = headerSize + length; + byte[] combined = new byte[totalSize]; + s_salt.CopyTo(combined, 0); + s_verifier.CopyTo(combined, s_salt.Length); + Buffer.BlockCopy(bytes, 0, combined, headerSize, length); + +#pragma warning disable IL2072 // dynamic invocation + return (Stream)_createMethod.Invoke( + obj: null, + parameters: [new MemoryStream(combined), s_keyMaterial, (long)totalSize, /*encrypting*/ false, /*leaveOpen*/ false])!; +#pragma warning restore IL2072 + } + + private byte[] CopyToRentedArray(ReadOnlySpan bytes) + { + byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); + try + { + bytes.CopyTo(buffer); + return buffer; + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private async Task TestStream(byte[] buffer, int length, bool async) + { + try + { + using var stream = CreateStream(buffer, length); + if (async) + { + await stream.CopyToAsync(Stream.Null); + } + else + { + stream.CopyTo(Stream.Null); + } + } + catch (InvalidDataException) + { + // ignore, this exception is expected for invalid/corrupted data. + } + catch (CryptographicException) + { + // ignore, crypto failures are expected for random fuzz input. + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs new file mode 100644 index 00000000000000..227fa7f5aa8977 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Compression; +using System.Reflection; +using System.Threading.Tasks; + +namespace DotnetFuzzing.Fuzzers; + +internal sealed class ZipCryptoStreamFuzzer : IFuzzer +{ + public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; + public string[] TargetCoreLibPrefixes => []; + public string Corpus => "zipcryptostream"; + + public void FuzzTarget(ReadOnlySpan bytes) + { + // ZipCryptoStream.Create reads a 12-byte header from the stream and validates the + // last decrypted byte against the expected check byte. Require at least 13 bytes + // (1 check byte + 12 header bytes) so the fuzzer can reach past the header. + if (bytes.Length < 13) + { + return; + } + + TestStream(CopyToRentedArray(bytes), bytes.Length, async: false).GetAwaiter().GetResult(); + TestStream(CopyToRentedArray(bytes), bytes.Length, async: true).GetAwaiter().GetResult(); + } + +#pragma warning disable IL2026 // RequiresUnreferencedCode + private static readonly Type _zipCryptoStreamType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; +#pragma warning restore IL2026 + +#pragma warning disable IL2077 // dynamic access to non-public members + private static readonly MethodInfo _createKeyMethod = _zipCryptoStreamType.GetMethod( + "CreateKey", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo _createMethod = _zipCryptoStreamType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool)], + modifiers: null)!; +#pragma warning restore IL2077 + + // Derive key bytes from a fixed password so the key state is realistic. + private static readonly byte[] s_keyBytes = (byte[])_createKeyMethod.Invoke( + obj: null, + parameters: ["fuzz".AsMemory()])!; + + private static Stream CreateStream(byte[] bytes, int length) + { + // Use the first byte of the input as the "expected check byte" so that the + // header validation path is exercised with varying values. + byte expectedCheckByte = bytes[0]; + var baseStream = new MemoryStream(bytes, 1, length - 1); +#pragma warning disable IL2072 // dynamic invocation + return (Stream)_createMethod.Invoke( + obj: null, + parameters: [baseStream, s_keyBytes, expectedCheckByte, /*encrypting*/ false, /*leaveOpen*/ false])!; +#pragma warning restore IL2072 + } + + private byte[] CopyToRentedArray(ReadOnlySpan bytes) + { + byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); + try + { + bytes.CopyTo(buffer); + return buffer; + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private async Task TestStream(byte[] buffer, int length, bool async) + { + try + { + using var stream = CreateStream(buffer, length); + if (async) + { + await stream.CopyToAsync(Stream.Null); + } + else + { + stream.CopyTo(Stream.Null); + } + } + catch (InvalidDataException) + { + // ignore, this exception is expected for invalid/corrupted data. + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} From 79ceee736461eb1d241f6085d68242b45edb73e5 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 11 Mar 2026 15:41:43 +0100 Subject: [PATCH 52/83] address security feedback and copilot comments --- .../ZipFileExtensions.ZipArchive.Extract.cs | 1 + ...xtensions.ZipArchiveEntry.Extract.Async.cs | 49 ++++- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 6 +- .../tests/ZipFile.Encryption.cs | 8 +- .../src/Resources/Strings.resx | 12 +- .../src/System.IO.Compression.csproj | 2 + .../IO/Compression/WinZipAesKeyMaterial.cs | 128 ++++++++++++ .../System/IO/Compression/WinZipAesStream.cs | 174 +++------------- .../IO/Compression/ZipArchiveEntry.Async.cs | 14 +- .../System/IO/Compression/ZipArchiveEntry.cs | 26 +-- .../src/System/IO/Compression/ZipBlocks.cs | 5 +- .../System/IO/Compression/ZipCryptoKeys.cs | 19 ++ .../System/IO/Compression/ZipCryptoStream.cs | 191 ++++++++++-------- 13 files changed, 363 insertions(+), 272 deletions(-) create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoKeys.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index a73250114c2db9..e996ca2b2c3cb9 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -138,6 +138,7 @@ public static void ExtractToDirectory(this ZipArchive source, string destination { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationDirectoryName); + ArgumentException.ThrowIfNullOrEmpty(password); foreach (ZipArchiveEntry entry in source.Entries) { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 25b48df53dc754..899a57edeccf74 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -123,25 +123,52 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(password); + cancellationToken.ThrowIfCancellationRequested(); ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); - FileStream fs = new FileStream(destinationFileName, fileStreamOptions); - await using (fs) + // When overwriting, extract to a temporary file first to avoid corrupting the destination file + // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). + string extractPath = destinationFileName; + string? tempPath = null; + + if (overwrite && File.Exists(destinationFileName)) { - Stream es; - if (!string.IsNullOrEmpty(password)) - es = await source.OpenAsync(password, cancellationToken: cancellationToken).ConfigureAwait(false); - else - es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); - await using (es) + tempPath = Path.GetTempFileName(); + extractPath = tempPath; + } + + try + { + FileStream fs = new FileStream(extractPath, fileStreamOptions); + await using (fs.ConfigureAwait(false)) { - await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + Stream es = await source.OpenAsync(password, cancellationToken: cancellationToken).ConfigureAwait(false); + await using (es.ConfigureAwait(false)) + { + await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + } } - } - ExtractToFileFinalize(source, destinationFileName); + // Move the temporary file to the destination only after successful extraction + if (tempPath is not null) + { + File.Move(tempPath, destinationFileName, overwrite: true); + } + + ExtractToFileFinalize(source, destinationFileName); + } + catch + { + // Clean up the temporary file if extraction failed + if (tempPath is not null && File.Exists(tempPath)) + { + try { File.Delete(tempPath); } catch { } + } + throw; + } } internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, CancellationToken cancellationToken = default) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 6fb11e0bc32bd4..16febf520ffe42 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -127,6 +127,8 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// The password used to decrypt the encrypted entry. public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { + ArgumentException.ThrowIfNullOrEmpty(password); + ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file @@ -144,7 +146,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination { using (FileStream fs = new FileStream(extractPath, fileStreamOptions)) { - using (Stream es = !string.IsNullOrEmpty(password) ? source.Open(password) : source.Open()) + using (Stream es = source.Open(password)) es.CopyTo(fs); } @@ -239,7 +241,7 @@ internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, str // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - if (!string.IsNullOrEmpty(password)) + if (password is not null) source.ExtractToFile(fileDestinationPath, overwrite: overwrite, password: password); else source.ExtractToFile(fileDestinationPath, overwrite: overwrite); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 01fa16f5293e5e..03f202ba66a545 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1418,15 +1418,15 @@ public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bo if (async) { - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "")); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "")); await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, null!)); await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "")); } else { - Assert.ThrowsAny(() => entry.Open(FileAccess.Read, null!)); - Assert.ThrowsAny(() => entry.Open(FileAccess.Read, "")); + Assert.ThrowsAny(() => entry.Open(FileAccess.Read, null!)); + Assert.ThrowsAny(() => entry.Open(FileAccess.Read, "")); Assert.ThrowsAny(() => entry.Open(FileAccess.ReadWrite, null!)); Assert.ThrowsAny(() => entry.Open(FileAccess.ReadWrite, "")); } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index 0d01327148f3a7..810db5b4016eea 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -310,13 +310,13 @@ An attempt was made to move the position before the beginning of the stream. - Stream size is too small for WinZip standard + The encrypted data size in the archive is invalid. - Invalid password + The password did not match. - Authentication code mismatch for WinZip encrypted entry. + The authentication code does not match. The archive may be corrupted. Entry is not encrypted. @@ -331,7 +331,7 @@ Invalid AES strength value. - Truncated ZipCrypto header. + The encryption header is truncated. CRC mismatch. @@ -346,6 +346,6 @@ Encryption Method should not be specified in Update Mode - WinZip encryption is not supported on browser - + Zip archive encryption is not supported on browser platform. + \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 04061f0a8a9d9e..4c2e081dcdbef8 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -56,6 +56,8 @@ + + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs new file mode 100644 index 00000000000000..9fd3db901503f8 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; + +namespace System.IO.Compression +{ + /// + /// Represents the parsed components of WinZip AES key material. + /// The key material layout is: [salt][encryption key][HMAC key][password verifier (2 bytes)]. + /// + [UnsupportedOSPlatform("browser")] + internal readonly struct WinZipAesKeyMaterial + { + public byte[] Salt { get; } + public byte[] EncryptionKey { get; } + public byte[] HmacKey { get; } + public byte[] PasswordVerifier { get; } + public int KeySizeBits { get; } + public int SaltSize { get; } + + private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, byte[] passwordVerifier, int keySizeBits) + { + Salt = salt; + EncryptionKey = encryptionKey; + HmacKey = hmacKey; + PasswordVerifier = passwordVerifier; + KeySizeBits = keySizeBits; + SaltSize = GetSaltSize(keySizeBits); + } + + /// + /// Parses raw key material bytes into their individual components. + /// Validates that the input length matches the expected layout for the given key size. + /// + internal static WinZipAesKeyMaterial Parse(byte[] keyMaterial, int keySizeBits) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int expectedSize = checked(saltSize + keySizeBytes + keySizeBytes + 2); + + if (keyMaterial.Length != expectedSize) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } + + int offset = 0; + + byte[] salt = new byte[saltSize]; + Array.Copy(keyMaterial, offset, salt, 0, saltSize); + offset += saltSize; + + byte[] encryptionKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); + offset += keySizeBytes; + + byte[] hmacKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); + offset += keySizeBytes; + + byte[] passwordVerifier = new byte[2]; + Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); + + return new WinZipAesKeyMaterial(salt, encryptionKey, hmacKey, passwordVerifier, keySizeBits); + } + + /// + /// Derives key material from a password and optional salt using PBKDF2-SHA1. + /// + internal static WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? salt, int keySizeBits) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int totalKeySize = checked(keySizeBytes + keySizeBytes + 2); + + byte[] saltBytes; + if (salt is null) + { + saltBytes = new byte[saltSize]; + RandomNumberGenerator.Fill(saltBytes); + } + else + { + if (salt.Length != saltSize) + { + throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); + } + saltBytes = salt; + } + + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + byte[] rentedPasswordBytes = System.Buffers.ArrayPool.Shared.Rent(maxPasswordByteCount); + // totalKeySize is at most 66 bytes (AES-256: 32 + 32 + 2), safe for stackalloc + Span derivedKey = stackalloc byte[totalKeySize]; + + try + { + int actualByteCount = Encoding.UTF8.GetBytes(password, rentedPasswordBytes); + Span passwordSpan = rentedPasswordBytes.AsSpan(0, actualByteCount); + + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + saltBytes, + derivedKey, + 1000, + HashAlgorithmName.SHA1); + + // Slice the derived key directly into its components instead of + // round-tripping through a combined array and Parse. + byte[] encryptionKey = derivedKey.Slice(0, keySizeBytes).ToArray(); + byte[] hmacKey = derivedKey.Slice(keySizeBytes, keySizeBytes).ToArray(); + byte[] passwordVerifier = derivedKey.Slice(keySizeBytes + keySizeBytes, 2).ToArray(); + + return new WinZipAesKeyMaterial(saltBytes, encryptionKey, hmacKey, passwordVerifier, keySizeBits); + } + finally + { + CryptographicOperations.ZeroMemory(rentedPasswordBytes); + CryptographicOperations.ZeroMemory(derivedKey); + System.Buffers.ArrayPool.Shared.Return(rentedPasswordBytes); + } + } + + internal static int GetSaltSize(int keySizeBits) => keySizeBits / 16; + } +} diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 42592d4f7008e5..0d4aa29cedfaad 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -11,122 +11,7 @@ namespace System.IO.Compression { - /// - /// Represents the parsed components of WinZip AES key material. - /// The key material layout is: [salt][encryption key][HMAC key][password verifier (2 bytes)]. - /// - internal readonly struct WinZipAesKeyMaterial - { - public byte[] Salt { get; } - public byte[] EncryptionKey { get; } - public byte[] HmacKey { get; } - public byte[] PasswordVerifier { get; } - public int KeySizeBits { get; } - public int SaltSize { get; } - - private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, byte[] passwordVerifier, int keySizeBits) - { - Salt = salt; - EncryptionKey = encryptionKey; - HmacKey = hmacKey; - PasswordVerifier = passwordVerifier; - KeySizeBits = keySizeBits; - SaltSize = GetSaltSize(keySizeBits); - } - - /// - /// Parses raw key material bytes into their individual components. - /// Validates that the input length matches the expected layout for the given key size. - /// - internal static WinZipAesKeyMaterial Parse(byte[] keyMaterial, int keySizeBits) - { - int saltSize = GetSaltSize(keySizeBits); - int keySizeBytes = keySizeBits / 8; - int expectedSize = checked(saltSize + keySizeBytes + keySizeBytes + 2); - - if (keyMaterial.Length != expectedSize) - { - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - } - - int offset = 0; - - byte[] salt = new byte[saltSize]; - Array.Copy(keyMaterial, offset, salt, 0, saltSize); - offset += saltSize; - - byte[] encryptionKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); - offset += keySizeBytes; - - byte[] hmacKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); - offset += keySizeBytes; - - byte[] passwordVerifier = new byte[2]; - Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); - - return new WinZipAesKeyMaterial(salt, encryptionKey, hmacKey, passwordVerifier, keySizeBits); - } - - /// - /// Derives key material from a password and optional salt using PBKDF2-SHA1. - /// - internal static WinZipAesKeyMaterial Create(ReadOnlyMemory password, byte[]? salt, int keySizeBits) - { - int saltSize = GetSaltSize(keySizeBits); - int keySizeBytes = keySizeBits / 8; - int totalKeySize = checked(keySizeBytes + keySizeBytes + 2); - - byte[] saltBytes; - if (salt is null) - { - saltBytes = new byte[saltSize]; - RandomNumberGenerator.Fill(saltBytes); - } - else - { - if (salt.Length != saltSize) - { - throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); - } - saltBytes = salt; - } - - int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); - byte[] rentedPasswordBytes = System.Buffers.ArrayPool.Shared.Rent(maxPasswordByteCount); - // totalKeySize is at most 66 bytes (AES-256: 32 + 32 + 2), safe for stackalloc - Span derivedKey = stackalloc byte[totalKeySize]; - - try - { - int actualByteCount = Encoding.UTF8.GetBytes(password.Span, rentedPasswordBytes); - Span passwordSpan = rentedPasswordBytes.AsSpan(0, actualByteCount); - - Rfc2898DeriveBytes.Pbkdf2( - passwordSpan, - saltBytes, - derivedKey, - 1000, - HashAlgorithmName.SHA1); - - byte[] rawMaterial = new byte[checked(saltSize + totalKeySize)]; - saltBytes.CopyTo(rawMaterial, 0); - derivedKey.CopyTo(rawMaterial.AsSpan(saltSize)); - - return Parse(rawMaterial, keySizeBits); - } - finally - { - CryptographicOperations.ZeroMemory(rentedPasswordBytes); - CryptographicOperations.ZeroMemory(derivedKey); - System.Buffers.ArrayPool.Shared.Return(rentedPasswordBytes); - } - } - - internal static int GetSaltSize(int keySizeBits) => keySizeBits / 16; - } - + [UnsupportedOSPlatform("browser")] [UnsupportedOSPlatform("browser")] internal sealed class WinZipAesStream : Stream { @@ -136,7 +21,6 @@ internal sealed class WinZipAesStream : Stream private readonly Stream _baseStream; private readonly bool _encrypting; private readonly Aes _aes; - private ICryptoTransform? _aesEncryptor; private IncrementalHash? _hmac; private UInt128 _counter = 1; private readonly byte[] _salt; @@ -155,12 +39,15 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + // Reusable work buffer for write operations, lazily allocated on first write + private byte[]? _writeWorkBuffer; + internal static int GetSaltSize(int keySizeBits) => WinZipAesKeyMaterial.GetSaltSize(keySizeBits); /// /// Derives key material from a password and optional salt. /// - internal static WinZipAesKeyMaterial CreateKey(ReadOnlyMemory password, byte[]? salt, int keySizeBits) + internal static WinZipAesKeyMaterial CreateKey(ReadOnlySpan password, byte[]? salt, int keySizeBits) => WinZipAesKeyMaterial.Create(password, salt, keySizeBits); /// @@ -275,8 +162,7 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon } _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, keyMaterial.HmacKey); - _aes.Key = keyMaterial.EncryptionKey; - _aesEncryptor = _aes.CreateEncryptor(); + _aes.SetKey(keyMaterial.EncryptionKey); } private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) @@ -289,9 +175,6 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can return; } - // Finalize HMAC computation - byte[] expectedAuth = _hmac.GetHashAndReset(); - // Read the 10-byte stored authentication code from the stream byte[] storedAuth = new byte[10]; @@ -304,15 +187,21 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can _baseStream.ReadExactly(storedAuth); } + // Finalize HMAC computation after reading, so we can use stackalloc + Span expectedAuth = stackalloc byte[20]; // SHA1 hash size + if (!_hmac.TryGetHashAndReset(expectedAuth, out int bytesWritten) || bytesWritten < 10) + { + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); + } + // Compare the first 10 bytes of the expected hash - if (!CryptographicOperations.FixedTimeEquals(storedAuth, expectedAuth.AsSpan(0, 10))) + if (!CryptographicOperations.FixedTimeEquals(storedAuth, expectedAuth.Slice(0, 10))) { throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } _authCodeValidated = true; } - private void ValidateAuthCode() { ValidateAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); @@ -356,7 +245,6 @@ private Task WriteHeaderAsync(CancellationToken cancellationToken) private void ProcessBlock(Span buffer) { - Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); Debug.Assert(_hmac is not null, "HMAC should have been initialized"); int processed = 0; @@ -397,18 +285,16 @@ private void ProcessBlock(Span buffer) private void GenerateKeystreamBuffer() { - Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); - - byte[] counterBlock = new byte[BlockSize]; - - // Generate KeystreamBufferSize bytes of keystream (256 blocks of 16 bytes each) + // Fill the buffer with all counter values first for (int i = 0; i < KeystreamBufferSize; i += BlockSize) { - BinaryPrimitives.WriteUInt128LittleEndian(counterBlock, _counter); - _aesEncryptor.TransformBlock(counterBlock, 0, BlockSize, _keystreamBuffer, i); + BinaryPrimitives.WriteUInt128LittleEndian(_keystreamBuffer.AsSpan(i, BlockSize), _counter); _counter++; } + // Encrypt all 256 counter blocks in a single call + _aes.EncryptEcb(_keystreamBuffer, _keystreamBuffer, PaddingMode.None); + _keystreamOffset = 0; } @@ -432,16 +318,23 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel return; } - byte[] authCode = _hmac.GetHashAndReset(); + // Finalize HMAC computation using stackalloc to avoid heap allocation + Span authCode = stackalloc byte[20]; // SHA1 hash size + if (!_hmac.TryGetHashAndReset(authCode, out int bytesWritten) || bytesWritten < 10) + { + throw new CryptographicException(); + } // WinZip AES spec requires only the first 10 bytes of the HMAC if (isAsync) { - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + // WriteAsync requires Memory, so we must copy to a heap buffer for the async path + byte[] authCodeArray = authCode.Slice(0, 10).ToArray(); + await _baseStream.WriteAsync(authCodeArray.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); } else { - _baseStream.Write(authCode, 0, 10); + _baseStream.Write(authCode.Slice(0, 10)); } _authCodeValidated = true; @@ -557,6 +450,8 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation return bytesRead; } + private byte[] GetWriteWorkBuffer() => _writeWorkBuffer ??= new byte[KeystreamBufferSize]; + private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) { int inputOffset = 0; @@ -627,8 +522,7 @@ public override void Write(ReadOnlySpan buffer) WriteHeader(); } - byte[] workBuffer = new byte[KeystreamBufferSize]; - WriteCore(buffer, workBuffer); + WriteCore(buffer, GetWriteWorkBuffer()); } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -648,7 +542,7 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation int inputOffset = 0; int inputCount = buffer.Length; - byte[] workBuffer = new byte[KeystreamBufferSize]; + byte[] workBuffer = GetWriteWorkBuffer(); // Fill the partial block buffer if it has data if (_partialBlockBytes > 0) @@ -749,7 +643,6 @@ protected override void Dispose(bool disposing) } finally { - _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac?.Dispose(); @@ -795,7 +688,6 @@ public override async ValueTask DisposeAsync() } finally { - _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac?.Dispose(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 078825b7741d33..c423ed79536c53 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -118,7 +118,7 @@ public async Task OpenAsync(FileAccess access, string password, Cancella ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) @@ -252,11 +252,7 @@ public async Task OpenAsync(string password, EncryptionMethod encryption switch (_archive.Mode) { case ZipArchiveMode.Read: - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + throw new InvalidOperationException(SR.EncryptionReadMode); case ZipArchiveMode.Create: return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: @@ -483,7 +479,7 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - byte[] keyMaterial = ZipCryptoStream.CreateKey(password); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); return await ZipCryptoStream.CreateAsync(compressedStream, keyMaterial, expectedCheckByte, encrypting: false, cancellationToken).ConfigureAwait(false); } else if (isAesEncrypted) @@ -504,7 +500,7 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre compressedStream.Seek(-saltSize, SeekOrigin.Current); // Derive key material from the provided password - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.Span, salt, keySizeBits); return await WinZipAesStream.CreateAsync( baseStream: compressedStream, @@ -679,7 +675,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can var encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, - keyBytes: _derivedZipCryptoKeyMaterial, + keys: _derivedZipCryptoKeyMaterial.Value, passwordVerifierLow2Bytes: verifierLow2Bytes, encrypting: true, crc32: null, 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 4942ff76e27569..d9c574d4f36c43 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 @@ -52,7 +52,7 @@ public partial class ZipArchiveEntry private ushort? _aeVersion; // Cached derived key material for encrypted entries to allow updating in place. // Only one of these is set at a time, depending on the encryption method. - private byte[]? _derivedZipCryptoKeyMaterial; + private ZipCryptoKeys? _derivedZipCryptoKeyMaterial; private WinZipAesKeyMaterial? _derivedAesKeyMaterial; internal const ushort WinZipAesMethod = 99; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -419,13 +419,13 @@ public Stream Open() /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. /// The requested access is not compatible with the archive's open mode. - /// The password provided is null. + /// The password provided is null. public Stream Open(string password) { ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword); } switch (_archive.Mode) { @@ -460,7 +460,7 @@ public Stream Open(string password, EncryptionMethod encryptionMethod) ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword); } switch (_archive.Mode) { @@ -533,7 +533,7 @@ public Stream Open(FileAccess access, string password) ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword); } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); @@ -1057,7 +1057,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - byte[] keyMaterial = ZipCryptoStream.CreateKey(password); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); return ZipCryptoStream.Create(compressedStream, keyMaterial, expectedCheckByte, encrypting: false); } else if (isAesEncrypted) @@ -1078,7 +1078,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor compressedStream.Seek(-saltSize, SeekOrigin.Current); // Derive key material from the provided password - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.Span, salt, keySizeBits); return WinZipAesStream.Create( baseStream: compressedStream, @@ -1170,17 +1170,17 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet { if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword); } Encryption = encryptionMethod; - byte[] keyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); targetStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, - keyBytes: keyMaterial, + keys: keyMaterial, passwordVerifierLow2Bytes: verifierLow2Bytes, encrypting: true, crc32: null, @@ -1202,7 +1202,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet int keySizeBits = GetAesKeySizeBits(encryptionMethod); // Derive key material from password with new random salt - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.AsSpan(), salt: null, keySizeBits); targetStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, @@ -1309,7 +1309,7 @@ private void SetupEncryptionKeyMaterial(string password) // Generate new salt and derive key material for AES // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(Encryption); - _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password.AsSpan(), salt: null, keySizeBits); // Encryption is already set from constructor (parsed from central directory AES extra field) } @@ -1699,7 +1699,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) using (var encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, - keyBytes: _derivedZipCryptoKeyMaterial, + keys: _derivedZipCryptoKeyMaterial.Value, passwordVerifierLow2Bytes: verifierLow2Bytes, encrypting: true, crc32: null, 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 472a1a69c389b7..9765d2830770e6 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 @@ -797,7 +797,7 @@ public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData public void WriteBlock(Stream stream) { - Span buffer = new byte[TotalSize]; + Span buffer = stackalloc byte[TotalSize]; WriteBlockCore(buffer); stream.Write(buffer); } @@ -812,12 +812,11 @@ public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationT private void WriteBlockCore(Span buffer) { BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), DataSize); BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); buffer[6] = (byte)'A'; buffer[7] = (byte)'E'; buffer[8] = AesStrength; - BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoKeys.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoKeys.cs new file mode 100644 index 00000000000000..9de9abde90503c --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoKeys.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.IO.Compression +{ + internal readonly struct ZipCryptoKeys + { + internal readonly uint Key0; + internal readonly uint Key1; + internal readonly uint Key2; + + internal ZipCryptoKeys(uint key0, uint key1, uint key2) + { + Key0 = key0; + Key1 = key1; + Key2 = key2; + } + } +} diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 49407e3ac3bb49..56f7846bae8fe9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -14,6 +14,8 @@ internal sealed class ZipCryptoStream : Stream { internal const int KeySize = 12; // 3 * sizeof(uint) + private const int EncryptionBufferSize = 4096; + private readonly bool _encrypting; private readonly Stream _base; private readonly bool _leaveOpen; @@ -27,6 +29,9 @@ internal sealed class ZipCryptoStream : Stream private uint _key2; private static readonly uint[] s_crc2Table = CreateCrc32Table(); + // Reusable work buffer for write operations, lazily allocated on first write + private byte[]? _writeWorkBuffer; + private static uint[] CreateCrc32Table() { var table = new uint[256]; @@ -57,28 +62,24 @@ private ZipCryptoStream(Stream baseStream, uint key0, uint key1, uint key2, bool /// /// Creates a ZipCryptoStream for decryption. Reads and validates the 12-byte header synchronously. /// - internal static ZipCryptoStream Create(Stream baseStream, byte[] keyBytes, byte expectedCheckByte, bool encrypting, bool leaveOpen = false) + internal static ZipCryptoStream Create(Stream baseStream, ZipCryptoKeys keys, byte expectedCheckByte, bool encrypting, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - ArgumentNullException.ThrowIfNull(keyBytes); - Debug.Assert(keyBytes.Length == KeySize, $"Key bytes must be exactly {KeySize} bytes."); Debug.Assert(!encrypting, "Use the overload with passwordVerifierLow2Bytes for encryption."); - (uint key0, uint key1, uint key2) = ReadAndValidateHeaderCore(isAsync: false, baseStream, keyBytes, expectedCheckByte, CancellationToken.None).GetAwaiter().GetResult(); + (uint key0, uint key1, uint key2) = ReadAndValidateHeaderCore(isAsync: false, baseStream, keys, expectedCheckByte, CancellationToken.None).GetAwaiter().GetResult(); return new ZipCryptoStream(baseStream, key0, key1, key2, leaveOpen); } /// /// Creates a ZipCryptoStream for decryption. Reads and validates the 12-byte header asynchronously. /// - internal static async Task CreateAsync(Stream baseStream, byte[] keyBytes, byte expectedCheckByte, bool encrypting, CancellationToken cancellationToken = default, bool leaveOpen = false) + internal static async Task CreateAsync(Stream baseStream, ZipCryptoKeys keys, byte expectedCheckByte, bool encrypting, CancellationToken cancellationToken = default, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - ArgumentNullException.ThrowIfNull(keyBytes); - Debug.Assert(keyBytes.Length == KeySize, $"Key bytes must be exactly {KeySize} bytes."); Debug.Assert(!encrypting, "Use the overload with passwordVerifierLow2Bytes for encryption."); - (uint key0, uint key1, uint key2) = await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyBytes, expectedCheckByte, cancellationToken).ConfigureAwait(false); + (uint key0, uint key1, uint key2) = await ReadAndValidateHeaderCore(isAsync: true, baseStream, keys, expectedCheckByte, cancellationToken).ConfigureAwait(false); return new ZipCryptoStream(baseStream, key0, key1, key2, leaveOpen); } @@ -86,23 +87,21 @@ internal static async Task CreateAsync(Stream baseStream, byte[ /// Creates a ZipCryptoStream for encryption. Only synchronous creation is needed since no I/O is performed here. /// internal static ZipCryptoStream Create(Stream baseStream, - byte[] keyBytes, + ZipCryptoKeys keys, ushort passwordVerifierLow2Bytes, bool encrypting, uint? crc32 = null, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - ArgumentNullException.ThrowIfNull(keyBytes); - Debug.Assert(keyBytes.Length == KeySize, $"Key bytes must be exactly {KeySize} bytes."); Debug.Assert(encrypting, "Use the overload with expectedCheckByte for decryption."); - return new ZipCryptoStream(baseStream, keyBytes, passwordVerifierLow2Bytes, crc32, leaveOpen); + return new ZipCryptoStream(baseStream, keys, passwordVerifierLow2Bytes, crc32, leaveOpen); } // Encryption constructor private ZipCryptoStream(Stream baseStream, - byte[] keyBytes, + ZipCryptoKeys keys, ushort passwordVerifierLow2Bytes, uint? crc32, bool leaveOpen) @@ -112,111 +111,118 @@ private ZipCryptoStream(Stream baseStream, _leaveOpen = leaveOpen; _verifierLow2Bytes = passwordVerifierLow2Bytes; _crc32ForHeader = crc32; - InitKeysFromKeyBytes(keyBytes); + _key0 = keys.Key0; + _key1 = keys.Key1; + _key2 = keys.Key2; } - // Creates the persisted key bytes from a password. - // The returned byte array contains the 3 ZipCrypto keys (key0, key1, key2) - // serialized as 12 bytes in little-endian format. - public static byte[] CreateKey(ReadOnlyMemory password) + // Creates the persisted key material from a password. + // Returns a struct of 3 integers to keep the key off the heap. + internal static ZipCryptoKeys CreateKey(ReadOnlyMemory password) { // Initialize keys with standard ZipCrypto initial values uint key0 = 305419896; uint key1 = 591751049; uint key2 = 878082192; - // ZipCrypto uses raw bytes; ASCII is the most interoperable - var bytes = Encoding.ASCII.GetBytes(password.ToArray()); - foreach (byte b in bytes) + // ASCII produces exactly 1 byte per char, so SegmentSize bytes is sufficient + // for SegmentSize chars. + const int SegmentSize = 32; + Span buf = stackalloc byte[SegmentSize]; + + ReadOnlySpan pwSpan = password.Span; + + while (!pwSpan.IsEmpty) { - UpdateKeys(ref key0, ref key1, ref key2, b); - } + ReadOnlySpan segment = pwSpan; + + if (segment.Length > SegmentSize) + { + segment = segment.Slice(0, SegmentSize); + } - // Serialize the 3 keys to bytes in little-endian format - byte[] keyBytes = new byte[KeySize]; - BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(0, 4), key0); - BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(4, 4), key1); - BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(8, 4), key2); + int byteCount = Encoding.ASCII.GetBytes(segment, buf); - return keyBytes; - } + foreach (byte b in buf.Slice(0, byteCount)) + { + UpdateKeys(ref key0, ref key1, ref key2, b); + } - // Initializes keys from persisted key bytes. - private void InitKeysFromKeyBytes(byte[] keyBytes) - { - _key0 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(0, 4)); - _key1 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(4, 4)); - _key2 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(8, 4)); + pwSpan = pwSpan.Slice(segment.Length); + } + + return new ZipCryptoKeys(key0, key1, key2); } - private byte[] CalculateHeader() + private void CalculateHeader(Span header) { - byte[] hdrPlain = new byte[12]; + if (header.Length < 12) + throw new ArgumentException("Header must be at least 12 bytes.", nameof(header)); - // bytes 0..9 are random - RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); + // bytes 0..9 random + RandomNumberGenerator.Fill(header.Slice(0, 10)); - // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) + // bytes 10..11 verifier if (_crc32ForHeader.HasValue) { uint crc = _crc32ForHeader.Value; - BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), (ushort)(crc >> 16)); + BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(10), (ushort)(crc >> 16)); } else { - BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), _verifierLow2Bytes); + BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(10), _verifierLow2Bytes); } - // Update keys with PLAINTEXT per spec - byte[] hdrCiph = new byte[12]; + // encrypt in place for (int i = 0; i < 12; i++) { + byte p = header[i]; byte ks = DecryptByte(_key2); - byte p = hdrPlain[i]; - hdrCiph[i] = (byte)(p ^ ks); + header[i] = (byte)(p ^ ks); + + // keys updated with PLAINTEXT per ZIP spec UpdateKeys(ref _key0, ref _key1, ref _key2, p); } - - return hdrCiph; } - private async ValueTask WriteHeaderCore(bool isAsync, CancellationToken cancellationToken = default) + private void WriteHeader() { if (!_encrypting || _headerWritten) - { return; - } - byte[] hdrCiph = CalculateHeader(); + Span header = stackalloc byte[12]; + CalculateHeader(header); + _base.Write(header); + _headerWritten = true; + } - if (isAsync) - { - await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); - } - else - { - _base.Write(hdrCiph, 0, 12); - } + private async ValueTask WriteHeaderAsync(CancellationToken cancellationToken) + { + if (!_encrypting || _headerWritten) + return; + byte[] header = new byte[12]; + CalculateHeader(header); + await _base.WriteAsync(header, cancellationToken).ConfigureAwait(false); _headerWritten = true; } private void EnsureHeader() { - WriteHeaderCore(isAsync: false).AsTask().GetAwaiter().GetResult(); + WriteHeader(); } private ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) { - return WriteHeaderCore(isAsync: true, cancellationToken); + return WriteHeaderAsync(cancellationToken); } - private static async Task<(uint key0, uint key1, uint key2)> ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, byte[] keyBytes, byte expectedCheckByte, CancellationToken cancellationToken) + private static async Task<(uint key0, uint key1, uint key2)> ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, ZipCryptoKeys keys, byte expectedCheckByte, CancellationToken cancellationToken) { // Initialize keys from input - uint key0 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(0, 4)); - uint key1 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(4, 4)); - uint key2 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(8, 4)); + uint key0 = keys.Key0; + uint key1 = keys.Key1; + uint key2 = keys.Key2; byte[] hdr = new byte[12]; int bytesRead; @@ -322,15 +328,24 @@ public override void Write(ReadOnlySpan buffer) EnsureHeader(); - byte[] tmp = new byte[buffer.Length]; - for (int i = 0; i < buffer.Length; i++) + byte[] workBuffer = GetWriteWorkBuffer(); + ReadOnlySpan remaining = buffer; + + while (!remaining.IsEmpty) { - byte ks = DecryptByte(_key2); - byte p = buffer[i]; - tmp[i] = (byte)(p ^ ks); - UpdateKeys(ref _key0, ref _key1, ref _key2, p); + int chunkSize = Math.Min(remaining.Length, workBuffer.Length); + + for (int i = 0; i < chunkSize; i++) + { + byte ks = DecryptByte(_key2); + byte p = remaining[i]; + workBuffer[i] = (byte)(p ^ ks); + UpdateKeys(ref _key0, ref _key1, ref _key2, p); + } + + _base.Write(workBuffer, 0, chunkSize); + remaining = remaining.Slice(chunkSize); } - _base.Write(tmp, 0, tmp.Length); } protected override void Dispose(bool disposing) @@ -421,22 +436,32 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella await EnsureHeaderAsync(cancellationToken).ConfigureAwait(false); - byte[] tmp = new byte[buffer.Length]; - ReadOnlySpan span = buffer.Span; - for (int i = 0; i < buffer.Length; i++) + byte[] workBuffer = GetWriteWorkBuffer(); + int offset = 0; + + while (offset < buffer.Length) { - byte ks = DecryptByte(_key2); - byte p = span[i]; - tmp[i] = (byte)(p ^ ks); - UpdateKeys(ref _key0, ref _key1, ref _key2, p); - } + int chunkSize = Math.Min(buffer.Length - offset, workBuffer.Length); + ReadOnlySpan span = buffer.Span; - await _base.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + for (int i = 0; i < chunkSize; i++) + { + byte ks = DecryptByte(_key2); + byte p = span[offset + i]; + workBuffer[i] = (byte)(p ^ ks); + UpdateKeys(ref _key0, ref _key1, ref _key2, p); + } + + await _base.WriteAsync(workBuffer.AsMemory(0, chunkSize), cancellationToken).ConfigureAwait(false); + offset += chunkSize; + } } public override Task FlushAsync(CancellationToken cancellationToken) { return _base.FlushAsync(cancellationToken); } + + private byte[] GetWriteWorkBuffer() => _writeWorkBuffer ??= new byte[EncryptionBufferSize]; } } From f6e0b05fb444686a7b47c0f11347460fc78d3313 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 13 Mar 2026 10:32:13 +0100 Subject: [PATCH 53/83] update password checks and fix reflection tests --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 17 +++++---- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 9 ++--- .../ZipFileExtensions.ZipArchive.Extract.cs | 8 ++--- .../System/IO/Compression/WinZipAesStream.cs | 1 - .../IO/Compression/ZipArchiveEntry.Async.cs | 12 ++++--- .../System/IO/Compression/ZipArchiveEntry.cs | 18 ++++++---- .../tests/WinZipAesStreamConformanceTests.cs | 35 +++++++++---------- .../tests/ZipCryptoStreamConformanceTests.cs | 19 +++++----- 8 files changed, 65 insertions(+), 54 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 87b7b31caa2d06..55c9d834b8da82 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -25,10 +25,17 @@ internal sealed class WinZipAesStreamFuzzer : IFuzzer private static readonly Type _winZipAesKeyMaterialType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; #pragma warning restore IL2026 + // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, + // so we use a strongly-typed delegate instead. + private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); + #pragma warning disable IL2077 // dynamic access to non-public members - private static readonly MethodInfo _createKeyMethod = _winZipAesStreamType.GetMethod( - "CreateKey", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly CreateKeyDelegate _createKey = _winZipAesKeyMaterialType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], + modifiers: null)!.CreateDelegate(); private static readonly MethodInfo _createMethod = _winZipAesStreamType.GetMethod( "Create", @@ -50,9 +57,7 @@ internal sealed class WinZipAesStreamFuzzer : IFuzzer // Pre-derive key material once with a fixed password and no salt so the fuzzer focuses // on the stream's decryption/HMAC logic rather than key derivation. - private static readonly object s_keyMaterial = _createKeyMethod.Invoke( - obj: null, - parameters: ["fuzz".AsMemory(), null, KeySizeBits])!; + private static readonly object s_keyMaterial = _createKey("fuzz", null, KeySizeBits); // Cache the salt and password verifier bytes for prepending to the fuzz input. private static readonly byte[] s_salt = (byte[])_saltProp.GetValue(s_keyMaterial)!; diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 227fa7f5aa8977..6fc0bdd34d2719 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -30,6 +30,7 @@ public void FuzzTarget(ReadOnlySpan bytes) #pragma warning disable IL2026 // RequiresUnreferencedCode private static readonly Type _zipCryptoStreamType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; + private static readonly Type _zipCryptoKeysType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; #pragma warning restore IL2026 #pragma warning disable IL2077 // dynamic access to non-public members @@ -41,12 +42,12 @@ public void FuzzTarget(ReadOnlySpan bytes) "Create", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, binder: null, - types: [typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool)], + types: [typeof(Stream), _zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool)], modifiers: null)!; #pragma warning restore IL2077 - // Derive key bytes from a fixed password so the key state is realistic. - private static readonly byte[] s_keyBytes = (byte[])_createKeyMethod.Invoke( + // Derive keys from a fixed password so the key state is realistic. + private static readonly object s_keys = _createKeyMethod.Invoke( obj: null, parameters: ["fuzz".AsMemory()])!; @@ -59,7 +60,7 @@ private static Stream CreateStream(byte[] bytes, int length) #pragma warning disable IL2072 // dynamic invocation return (Stream)_createMethod.Invoke( obj: null, - parameters: [baseStream, s_keyBytes, expectedCheckByte, /*encrypting*/ false, /*leaveOpen*/ false])!; + parameters: [baseStream, s_keys, expectedCheckByte, /*encrypting*/ false, /*leaveOpen*/ false])!; #pragma warning restore IL2072 } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index e996ca2b2c3cb9..8bbbbcdc180024 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -20,7 +20,7 @@ public static partial class ZipFileExtensions /// The specified path, file name, or both exceed the system-defined maximum length. /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). /// -or- An archive entry has the same name as an already extracted entry from the same archive. @@ -50,7 +50,7 @@ public static void ExtractToDirectory(this ZipArchive source, string destination /// The specified path, file name, or both exceed the system-defined maximum length. /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). /// -or- An archive entry has the same name as an already extracted entry from the same archive. @@ -89,7 +89,7 @@ public static void ExtractToDirectory(this ZipArchive source, string destination /// The specified path, file name, or both exceed the system-defined maximum length. /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). /// -or- An archive entry has the same name as an already extracted entry from the same archive. @@ -120,7 +120,7 @@ public static void ExtractToDirectory(this ZipArchive source, string destination /// The specified path, file name, or both exceed the system-defined maximum length. /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). /// -or- An archive entry has the same name as an already extracted entry from the same archive. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 0d4aa29cedfaad..3184049f1084ec 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -11,7 +11,6 @@ namespace System.IO.Compression { - [UnsupportedOSPlatform("browser")] [UnsupportedOSPlatform("browser")] internal sealed class WinZipAesStream : Stream { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index c423ed79536c53..e20f8964a50279 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -116,7 +116,7 @@ public async Task OpenAsync(FileAccess access, string password, Cancella { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.Length == 0) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -185,10 +185,14 @@ public Task OpenAsync(FileAccess access, string password, EncryptionMeth throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); } - if (string.IsNullOrEmpty(password)) + if (password is null) { throw new ArgumentNullException(nameof(password), SR.EmptyPassword); } + if (password.Length == 0) + { + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } switch (_archive.Mode) { @@ -214,9 +218,9 @@ public async Task OpenAsync(string password, CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.Length == 0) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } switch (_archive.Mode) 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 d9c574d4f36c43..4dbc77231e8f51 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 @@ -419,13 +419,13 @@ public Stream Open() /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. /// The requested access is not compatible with the archive's open mode. - /// The password provided is null. + /// The password provided is empty. public Stream Open(string password) { ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentException(SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } switch (_archive.Mode) { @@ -460,7 +460,7 @@ public Stream Open(string password, EncryptionMethod encryptionMethod) ThrowIfInvalidArchive(); if (string.IsNullOrEmpty(password)) { - throw new ArgumentException(SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } switch (_archive.Mode) { @@ -531,9 +531,9 @@ public Stream Open(FileAccess access) public Stream Open(FileAccess access, string password) { ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.Length == 0) { - throw new ArgumentException(SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); @@ -571,6 +571,10 @@ public Stream Open(FileAccess access, string password) public Stream Open(FileAccess access, string password, EncryptionMethod encryptionMethod) { ThrowIfInvalidArchive(); + if (password.Length == 0) + { + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); @@ -1170,7 +1174,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet { if (string.IsNullOrEmpty(password)) { - throw new ArgumentException(SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } Encryption = encryptionMethod; @@ -1195,7 +1199,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet } if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException(SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); Encryption = encryptionMethod; diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 7bf06eaeedce98..74335079aff07a 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -38,7 +38,9 @@ public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConforma private const string TestPassword = "test-password"; private static readonly Type s_winZipAesStreamType; - private static readonly MethodInfo s_createKeyMethod; + private static readonly Type s_winZipAesKeyMaterialType; + private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); + private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createMethod; protected abstract int KeySizeBits { get; } @@ -48,17 +50,21 @@ static WinZipAesStreamConformanceTests() { var assembly = typeof(ZipArchive).Assembly; s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; + s_winZipAesKeyMaterialType = assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; - s_createKeyMethod = s_winZipAesStreamType.GetMethod("CreateKey", + // Use WinZipAesKeyMaterial.Create directly since it's the underlying implementation + // and we need a delegate to handle ReadOnlySpan (ref struct can't be boxed for MethodInfo.Invoke) + MethodInfo createKeyMethod = s_winZipAesKeyMaterialType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(ReadOnlyMemory), typeof(byte[]), typeof(int) }, + new[] { typeof(ReadOnlySpan), typeof(byte[]), typeof(int) }, null)!; + s_createKey = createKeyMethod.CreateDelegate(); s_createMethod = s_winZipAesStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(int), typeof(long), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), s_winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool) }, null)!; } @@ -70,14 +76,11 @@ static WinZipAesStreamConformanceTests() protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) { var ms = new MemoryStream(); - byte[] keyMaterial = (byte[])s_createKeyMethod.Invoke(null, new object?[] - { - TestPassword.AsMemory(), null, KeySizeBits - })!; + object keyMaterial = s_createKey(TestPassword, null, KeySizeBits); var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - ms, keyMaterial, KeySizeBits, 0L, true, false + ms, keyMaterial, 0L, true, false })!; if (initialData != null && initialData.Length > 0) @@ -93,16 +96,13 @@ static WinZipAesStreamConformanceTests() byte[] plaintext = initialData ?? Array.Empty(); // Create key material for encryption (generates random salt) - byte[] encryptKeyMaterial = (byte[])s_createKeyMethod.Invoke(null, new object?[] - { - TestPassword.AsMemory(), null, KeySizeBits - })!; + object encryptKeyMaterial = s_createKey(TestPassword, null, KeySizeBits); // Encrypt data first using var encryptedMs = new MemoryStream(); using (var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - encryptedMs, encryptKeyMaterial, KeySizeBits, 0L, true, true + encryptedMs, encryptKeyMaterial, 0L, true, true })!) { encryptStream.Write(plaintext); @@ -113,16 +113,13 @@ static WinZipAesStreamConformanceTests() byte[] salt = new byte[SaltSize]; Array.Copy(encryptedData, 0, salt, 0, SaltSize); - byte[] decryptKeyMaterial = (byte[])s_createKeyMethod.Invoke(null, new object?[] - { - TestPassword.AsMemory(), salt, KeySizeBits - })!; + object decryptKeyMaterial = s_createKey(TestPassword, salt, KeySizeBits); // Create decryption stream over the encrypted data var ms = new MemoryStream(encryptedData); var decryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - ms, decryptKeyMaterial, KeySizeBits, (long)encryptedData.Length, false, false + ms, decryptKeyMaterial, (long)encryptedData.Length, false, false })!; return Task.FromResult(decryptStream); diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index 0e30031c2395ab..ccab3edbcc1cc5 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -1,4 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Compression; @@ -20,6 +19,7 @@ public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests private const ushort PasswordVerifier = 0x1234; private static readonly Type s_zipCryptoStreamType; + private static readonly Type s_zipCryptoKeysType; private static readonly MethodInfo s_createKeyMethod; private static readonly MethodInfo s_createEncryptionMethod; private static readonly MethodInfo s_createDecryptionMethod; @@ -28,9 +28,10 @@ static ZipCryptoStreamConformanceTests() { var assembly = typeof(ZipArchive).Assembly; s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; + s_zipCryptoKeysType = assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; s_createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", - BindingFlags.Public | BindingFlags.Static, + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(ReadOnlyMemory) }, null)!; @@ -38,13 +39,13 @@ static ZipCryptoStreamConformanceTests() s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + new[] { typeof(Stream), s_zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, null)!; s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, null)!; } @@ -57,11 +58,11 @@ static ZipCryptoStreamConformanceTests() protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) { var ms = new MemoryStream(); - byte[] keyBytes = (byte[])s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + object keys = s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { - ms, keyBytes, PasswordVerifier, true /* encrypting */, null /* crc32 */, false /* leaveOpen */ + ms, keys, PasswordVerifier, true /* encrypting */, null /* crc32 */, false /* leaveOpen */ })!; if (initialData != null && initialData.Length > 0) @@ -75,7 +76,7 @@ static ZipCryptoStreamConformanceTests() protected override Task CreateReadOnlyStreamCore(byte[]? initialData) { byte[] plaintext = initialData ?? Array.Empty(); - byte[] keyBytes = (byte[])s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + object keys = s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; // The check byte is the HIGH byte of the password verifier (little-endian format) byte expectedCheckByte = (byte)(PasswordVerifier >> 8); @@ -84,7 +85,7 @@ static ZipCryptoStreamConformanceTests() using var encryptedMs = new MemoryStream(); using (var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { - encryptedMs, keyBytes, PasswordVerifier, true /* encrypting */, null /* crc32 */, true /* leaveOpen */ + encryptedMs, keys, PasswordVerifier, true /* encrypting */, null /* crc32 */, true /* leaveOpen */ })!) { encryptStream.Write(plaintext); @@ -96,7 +97,7 @@ static ZipCryptoStreamConformanceTests() var ms = new MemoryStream(encryptedData); var decryptStream = (Stream)s_createDecryptionMethod.Invoke(null, new object[] { - ms, keyBytes, expectedCheckByte, false /* encrypting */, false /* leaveOpen */ + ms, keys, expectedCheckByte, false /* encrypting */, false /* leaveOpen */ })!; return Task.FromResult(decryptStream); From ac828688d7c9a7f85f885663a9a11ea8e2258741 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 16 Mar 2026 16:05:02 +0100 Subject: [PATCH 54/83] update string password params to take either readonlymemory or readonlyspan --- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 12 ++-- .../ref/System.IO.Compression.ZipFile.cs | 58 +++++++++--------- .../src/Resources/Strings.resx | 3 + .../IO/Compression/ZipFile.Extract.Async.cs | 16 ++--- .../System/IO/Compression/ZipFile.Extract.cs | 16 ++--- ...pFileExtensions.ZipArchive.Create.Async.cs | 6 +- .../ZipFileExtensions.ZipArchive.Create.cs | 6 +- ...FileExtensions.ZipArchive.Extract.Async.cs | 4 +- .../ZipFileExtensions.ZipArchive.Extract.cs | 9 ++- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 11 ++-- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 13 ++-- .../tests/ZipFile.Encryption.cs | 32 +++++----- .../tests/ZipFile.Extract.cs | 4 +- .../ref/System.IO.Compression.cs | 16 ++--- .../IO/Compression/ZipArchiveEntry.Async.cs | 46 +++++++------- .../System/IO/Compression/ZipArchiveEntry.cs | 60 +++++++++---------- .../System/IO/Compression/ZipCryptoStream.cs | 4 +- 17 files changed, 163 insertions(+), 153 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 6fc0bdd34d2719..5912588737ca7f 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -33,10 +33,14 @@ public void FuzzTarget(ReadOnlySpan bytes) private static readonly Type _zipCryptoKeysType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; #pragma warning restore IL2026 + // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, + // so we use a strongly-typed delegate instead. + private delegate object CreateKeyDelegate(ReadOnlySpan password); + #pragma warning disable IL2077 // dynamic access to non-public members - private static readonly MethodInfo _createKeyMethod = _zipCryptoStreamType.GetMethod( + private static readonly CreateKeyDelegate _createKey = _zipCryptoStreamType.GetMethod( "CreateKey", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!.CreateDelegate(); private static readonly MethodInfo _createMethod = _zipCryptoStreamType.GetMethod( "Create", @@ -47,9 +51,7 @@ public void FuzzTarget(ReadOnlySpan bytes) #pragma warning restore IL2077 // Derive keys from a fixed password so the key state is realistic. - private static readonly object s_keys = _createKeyMethod.Invoke( - obj: null, - parameters: ["fuzz".AsMemory()])!; + private static readonly object s_keys = _createKey("fuzz"); private static Stream CreateStream(byte[] bytes, int length) { diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 211d0640e62acf..b13263a9ee2069 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -22,34 +22,34 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, string password) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.ReadOnlySpan password) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlySpan password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.ReadOnlySpan password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlySpan password) { } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.IO.Compression.ZipArchive Open(string archiveFileName, System.IO.Compression.ZipArchiveMode mode) { throw null; } @@ -59,32 +59,32 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti public static System.IO.Compression.ZipArchive OpenRead(string archiveFileName) { throw null; } public static System.Threading.Tasks.Task OpenReadAsync(string archiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] + [System.ComponentModel.EditorBrowsableAttribute(1)] public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } - public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.EncryptionMethod encryption) { throw null; } - public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, string password, System.IO.Compression.EncryptionMethod encryption) { throw null; } - public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryption) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryption) { throw null; } + public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, string password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) { } - public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password) { } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.ReadOnlySpan password) { } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } - public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } - public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.ReadOnlySpan password) { } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.ReadOnlySpan password) { } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/Resources/Strings.resx b/src/libraries/System.IO.Compression.ZipFile/src/Resources/Strings.resx index 2a361b9d8ca4f9..4dc66287ab2067 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression.ZipFile/src/Resources/Strings.resx @@ -112,4 +112,7 @@ The stream is unwritable. + + The password cannot be empty. + diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index 51dcedaf1a2055..f0e252bbc73c4e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -238,7 +238,7 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); /// @@ -275,7 +275,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); /// @@ -333,7 +333,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); /// @@ -392,7 +392,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -592,7 +592,7 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati /// An archive entry was compressed by using a compression method that is not supported. /// An asynchronous operation is cancelled. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); /// @@ -625,7 +625,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// An archive entry was compressed by using a compression method that is not supported. /// An asynchronous operation is cancelled. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); /// @@ -666,7 +666,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// An archive entry was compressed by using a compression method that is not supported. /// An asynchronous operation is cancelled. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); /// @@ -708,7 +708,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// An archive entry was compressed by using a compression method that is not supported. /// An asynchronous operation is cancelled. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + public static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs index 7da2b74ee2f91f..50824ffe223518 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs @@ -218,7 +218,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the archive on the file system that is to be extracted. /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) => + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, ReadOnlySpan password) => ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); /// @@ -252,7 +252,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) => + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) => ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); /// @@ -307,7 +307,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// otherwise an is thrown. /// /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlySpan password) => ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); /// @@ -363,7 +363,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// otherwise an is thrown. /// /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) { ArgumentNullException.ThrowIfNull(sourceArchiveFileName); @@ -538,7 +538,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// An archive entry was not found or was corrupt. /// -or- /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, string password) => + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, ReadOnlySpan password) => ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); /// @@ -568,7 +568,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// An archive entry was not found or was corrupt. /// -or- /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles, string password) => + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) => ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); /// @@ -606,7 +606,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// An archive entry was not found or was corrupt. /// -or- /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlySpan password) => ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); /// @@ -645,7 +645,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// An archive entry was not found or was corrupt. /// -or- /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) { ArgumentNullException.ThrowIfNull(source); if (!source.CanRead) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs index 5a5786014815c9..d1dde656c7e975 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs @@ -72,7 +72,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, - string sourceFileName, string entryName, string password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => + string sourceFileName, string entryName, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, null, password, encryption, cancellationToken); /// @@ -136,7 +136,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel compressionLevel, string password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => + string sourceFileName, string entryName, CompressionLevel compressionLevel, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, compressionLevel, password, encryption, cancellationToken); internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, @@ -159,7 +159,7 @@ internal static async Task DoCreateEntryFromFileAsync(this ZipA } internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, - CompressionLevel? compressionLevel, string password, EncryptionMethod encryption, CancellationToken cancellationToken) + CompressionLevel? compressionLevel, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 7dc447d45dc5f3..fd1429e236b5fc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -77,7 +77,7 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); - public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, string password, EncryptionMethod encryption) => + public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, ReadOnlySpan password, EncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, null, password, encryption); @@ -113,7 +113,7 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, s /// A wrapper for the newly created entry. public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel, - string password, EncryptionMethod encryption) => + ReadOnlySpan password, EncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, @@ -134,7 +134,7 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, - string password, EncryptionMethod encryption) + ReadOnlySpan password, EncryptionMethod encryption) { (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index 0aac6b396a03fc..60011a666e8a8c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -113,7 +113,7 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + public static Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => ExtractToDirectoryAsync(source, destinationDirectoryName, overwriteFiles: false, password, cancellationToken); /// @@ -147,7 +147,7 @@ public static Task ExtractToDirectoryAsync(this ZipArchive source, string destin /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index 8bbbbcdc180024..2bfd9978e31758 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -102,7 +102,7 @@ public static void ExtractToDirectory(this ZipArchive source, string destination /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. /// Relative path information is interpreted as relative to the current working directory. /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, string password) => + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, ReadOnlySpan password) => ExtractToDirectory(source, destinationDirectoryName, overwriteFiles: false, password: password); /// @@ -134,11 +134,14 @@ public static void ExtractToDirectory(this ZipArchive source, string destination /// Relative path information is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationDirectoryName); - ArgumentException.ThrowIfNullOrEmpty(password); + if (password.IsEmpty) + { + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } foreach (ZipArchiveEntry entry in source.Entries) { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 899a57edeccf74..d603e848f5a3bb 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -118,12 +118,15 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string } } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string password, CancellationToken cancellationToken = default) => + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, CancellationToken cancellationToken = default) + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(password); + if (password.IsEmpty) + { + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } cancellationToken.ThrowIfCancellationRequested(); @@ -184,7 +187,7 @@ internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry } } - internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string password, CancellationToken cancellationToken = default) + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 16febf520ffe42..37913814fc046f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -113,7 +113,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// The zip archive entry to extract a file from. /// The name of the file that will hold the contents of the entry. /// The password used to decrypt the encrypted entry. - public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, string password) => + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, ReadOnlySpan password) => ExtractToFile(source, destinationFileName, overwrite: false, password: password); /// @@ -125,9 +125,12 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination /// The name of the file that will hold the contents of the entry. /// True to indicate overwrite. /// The password used to decrypt the encrypted entry. - public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlySpan password) { - ArgumentException.ThrowIfNullOrEmpty(password); + if (password.IsEmpty) + { + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); @@ -234,14 +237,14 @@ private static bool ExtractRelativeToDirectoryCheckIfFile(ZipArchiveEntry source return true; // It is a file } - internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string? password = null) + internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, ReadOnlySpan password = default) { if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) { // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - if (password is not null) + if (!password.IsEmpty) source.ExtractToFile(fileDestinationPath, overwrite: overwrite, password: password); else source.ExtractToFile(fileDestinationPath, overwrite: overwrite); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 03f202ba66a545..1e7addfa7d6521 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -237,7 +237,7 @@ public async Task ExtractToFile_Encrypted_Success(bool async) if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password.AsMemory()); Assert.Equal("content", await File.ReadAllTextAsync(destFile)); } else @@ -1121,7 +1121,7 @@ public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encrypti if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: false, password: password); + await entry.ExtractToFileAsync(destFile, overwrite: false, password: password.AsMemory()); Assert.Equal(content, await File.ReadAllTextAsync(destFile)); } else @@ -1154,7 +1154,7 @@ public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(EncryptionMetho if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password.AsMemory()); Assert.Equal(content, await File.ReadAllTextAsync(destFile)); } else @@ -1183,7 +1183,7 @@ public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) if (async) { - await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, overwrite: false, password: "WrongPassword")); + await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, overwrite: false, password: "WrongPassword".AsMemory())); } else { @@ -1218,7 +1218,7 @@ public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Succe { if (async) { - await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password); + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password.AsMemory()); } else { @@ -1258,7 +1258,7 @@ public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(b if (async) { await Assert.ThrowsAsync(() => - archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, "Password1")); + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, "Password1".AsMemory())); } else { @@ -1292,7 +1292,7 @@ public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(EncryptionMe { if (async) { - await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: true, password); + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: true, password.AsMemory()); } else { @@ -1328,7 +1328,7 @@ public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Thro if (async) { await Assert.ThrowsAsync(() => - archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password)); + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password.AsMemory())); } else { @@ -1361,8 +1361,8 @@ public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) if (async) { - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, password)); - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.ReadWrite, password)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, password.AsMemory())); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.ReadWrite, password.AsMemory())); } else { @@ -1386,10 +1386,10 @@ public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) if (async) { // Read access in create mode throws - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password")); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password".AsMemory())); // Encryption without password throws await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, null!, EncryptionMethod.Aes256)); - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, "", EncryptionMethod.Aes256)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, "".AsMemory(), EncryptionMethod.Aes256)); } else { @@ -1419,9 +1419,9 @@ public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bo if (async) { await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "")); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "".AsMemory())); await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "")); + await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "".AsMemory())); } else { @@ -1470,9 +1470,9 @@ public async Task CreateEntryFromFile_Encrypted_RoundTrip(EncryptionMethod encry if (async) { if (useCompression) - await archive.CreateEntryFromFileAsync(sourcePath, entryName, CompressionLevel.Optimal, password, encryptionMethod); + await archive.CreateEntryFromFileAsync(sourcePath, entryName, CompressionLevel.Optimal, password.AsMemory(), encryptionMethod); else - await archive.CreateEntryFromFileAsync(sourcePath, entryName, password, encryptionMethod); + await archive.CreateEntryFromFileAsync(sourcePath, entryName, password.AsMemory(), encryptionMethod); } else { diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 950a06c809a82d..78037c372726ac 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -321,7 +321,7 @@ public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789"); + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789".AsMemory()); Assert.True(File.Exists(tempFile), "Extracted file was not created."); string content = await File.ReadAllTextAsync(tempFile); @@ -345,7 +345,7 @@ public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() await Assert.ThrowsAsync(async () => { - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "wrongpass"); + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "wrongpass".AsMemory()); }); } 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 35067e016a30a8..d3627559e3aa97 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -136,15 +136,15 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } - public System.IO.Stream Open(System.IO.FileAccess access, string password) { throw null; } - public System.IO.Stream Open(System.IO.FileAccess access, string password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } - public System.IO.Stream Open(string password) { throw null; } - public System.IO.Stream Open(string password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, string password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password) { throw null; } + public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } + public System.IO.Stream Open(System.ReadOnlySpan password) { throw null; } + public System.IO.Stream Open(System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index e20f8964a50279..7452c393032b5a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -112,11 +112,11 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell /// The entry is not encrypted. /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. /// The ZipArchive that this entry belongs to has been disposed. - public async Task OpenAsync(FileAccess access, string password, CancellationToken cancellationToken = default) + public async Task OpenAsync(FileAccess access, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (password.Length == 0) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -131,7 +131,7 @@ public async Task OpenAsync(FileAccess access, string password, Cancella throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); if (!IsEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); case ZipArchiveMode.Create: throw new InvalidOperationException(SR.EntriesInCreateMode); @@ -144,7 +144,7 @@ public async Task OpenAsync(FileAccess access, string password, Cancella switch (access) { case FileAccess.Read: - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); case FileAccess.Write: return await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken, password).ConfigureAwait(false); case FileAccess.ReadWrite: @@ -175,7 +175,7 @@ public async Task OpenAsync(FileAccess access, string password, Cancella /// The archive is in update mode. /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. /// The ZipArchive that this entry belongs to has been disposed. - public Task OpenAsync(FileAccess access, string password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) + public Task OpenAsync(FileAccess access, ReadOnlyMemory password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -185,11 +185,7 @@ public Task OpenAsync(FileAccess access, string password, EncryptionMeth throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); } - if (password is null) - { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); - } - if (password.Length == 0) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -204,7 +200,7 @@ public Task OpenAsync(FileAccess access, string password, EncryptionMeth case ZipArchiveMode.Create: if (access == FileAccess.Read) throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return Task.FromResult(OpenInWriteMode(password, encryptionMethod)); + return Task.FromResult(OpenInWriteMode(password.Span, encryptionMethod)); case ZipArchiveMode.Update: default: @@ -214,11 +210,11 @@ public Task OpenAsync(FileAccess access, string password, EncryptionMeth } - public async Task OpenAsync(string password, CancellationToken cancellationToken = default) + public async Task OpenAsync(ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (password.Length == 0) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -230,7 +226,7 @@ public async Task OpenAsync(string password, CancellationToken cancellat { throw new InvalidDataException(SR.EntryNotEncrypted); } - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); case ZipArchiveMode.Create: throw new InvalidOperationException(SR.EncryptionNotSpecified); case ZipArchiveMode.Update: @@ -244,13 +240,13 @@ public async Task OpenAsync(string password, CancellationToken cancellat } } - public async Task OpenAsync(string password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) + public async Task OpenAsync(ReadOnlyMemory password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.IsEmpty) { - throw new ArgumentNullException(nameof(password), SR.EmptyPassword); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); } switch (_archive.Mode) @@ -258,7 +254,7 @@ public async Task OpenAsync(string password, EncryptionMethod encryption case ZipArchiveMode.Read: throw new InvalidOperationException(SR.EncryptionReadMode); case ZipArchiveMode.Create: - return OpenInWriteMode(password, encryptionMethod); + return OpenInWriteMode(password.Span, encryptionMethod); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -288,7 +284,7 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance return _storedOffsetOfCompressedData.Value; } - private async Task GetUncompressedDataAsync(CancellationToken cancellationToken, string? password = null) + private async Task GetUncompressedDataAsync(CancellationToken cancellationToken, ReadOnlyMemory password = default) { cancellationToken.ThrowIfCancellationRequested(); if (_storedUncompressedData == null) @@ -304,8 +300,8 @@ private async Task GetUncompressedDataAsync(CancellationToken canc if (_originallyInArchive) { - Stream decompressor = password != null - ? await OpenInReadModeAsync(checkOpenable: false, cancellationToken, password.AsMemory()).ConfigureAwait(false) + Stream decompressor = !password.IsEmpty + ? await OpenInReadModeAsync(checkOpenable: false, cancellationToken, password).ConfigureAwait(false) : await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); await using (decompressor) @@ -483,7 +479,7 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password.Span); return await ZipCryptoStream.CreateAsync(compressedStream, keyMaterial, expectedCheckByte, encrypting: false, cancellationToken).ConfigureAwait(false); } else if (isAesEncrypted) @@ -519,14 +515,14 @@ private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStre return compressedStream; } - private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default, string? password = null) + private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default, ReadOnlyMemory password = default) { cancellationToken.ThrowIfCancellationRequested(); if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); // Validate password requirement for encrypted entries - if (loadExistingContent && IsEncrypted && string.IsNullOrEmpty(password)) + if (loadExistingContent && IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); if (loadExistingContent) @@ -543,7 +539,7 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent // For encrypted entries, set up key material for re-encryption if (IsEncrypted) { - SetupEncryptionKeyMaterial(password!); + SetupEncryptionKeyMaterial(password.Span); } } else 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 4dbc77231e8f51..0a964bd771ecd1 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 @@ -420,10 +420,10 @@ public Stream Open() /// The ZipArchive that this entry belongs to has been disposed. /// The requested access is not compatible with the archive's open mode. /// The password provided is empty. - public Stream Open(string password) + public Stream Open(ReadOnlySpan password) { ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -434,7 +434,7 @@ public Stream Open(string password) { throw new InvalidDataException(SR.EntryNotEncrypted); } - return OpenInReadMode(checkOpenable: true, password.AsMemory()); + return OpenInReadMode(checkOpenable: true, password); case ZipArchiveMode.Create: throw new InvalidOperationException(SR.EntriesInCreateMode); case ZipArchiveMode.Update: @@ -455,10 +455,10 @@ public Stream Open(string password) /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(string password, EncryptionMethod encryptionMethod) + public Stream Open(ReadOnlySpan password, EncryptionMethod encryptionMethod) { ThrowIfInvalidArchive(); - if (string.IsNullOrEmpty(password)) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -528,10 +528,10 @@ public Stream Open(FileAccess access) } } - public Stream Open(FileAccess access, string password) + public Stream Open(FileAccess access, ReadOnlySpan password) { ThrowIfInvalidArchive(); - if (password.Length == 0) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -545,7 +545,7 @@ public Stream Open(FileAccess access, string password) throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); if (!IsEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); - return OpenInReadMode(checkOpenable: true, password.AsMemory()); + return OpenInReadMode(checkOpenable: true, password); case ZipArchiveMode.Create: throw new InvalidOperationException(SR.EntriesInCreateMode); @@ -558,7 +558,7 @@ public Stream Open(FileAccess access, string password) switch (access) { case FileAccess.Read: - return OpenInReadMode(checkOpenable: true, password.AsMemory()); + return OpenInReadMode(checkOpenable: true, password); case FileAccess.Write: return OpenInUpdateMode(loadExistingContent: false, password); case FileAccess.ReadWrite: @@ -568,10 +568,10 @@ public Stream Open(FileAccess access, string password) } } - public Stream Open(FileAccess access, string password, EncryptionMethod encryptionMethod) + public Stream Open(FileAccess access, ReadOnlySpan password, EncryptionMethod encryptionMethod) { ThrowIfInvalidArchive(); - if (password.Length == 0) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } @@ -644,7 +644,7 @@ internal long GetOffsetOfCompressedData() return _storedOffsetOfCompressedData.Value; } - private MemoryStream GetUncompressedData(string? password = null) + private MemoryStream GetUncompressedData(ReadOnlySpan password = default) { if (_storedUncompressedData == null) { @@ -660,8 +660,8 @@ private MemoryStream GetUncompressedData(string? password = null) if (_originallyInArchive) { - Stream decompressor = password != null - ? OpenInReadMode(checkOpenable: false, password.AsMemory()) + Stream decompressor = !password.IsEmpty + ? OpenInReadMode(checkOpenable: false, password) : OpenInReadMode(checkOpenable: false); using (decompressor) @@ -1051,7 +1051,7 @@ private static int GetAesKeySizeBits(EncryptionMethod encryption) } // Creates the appropriate decryption stream for an encrypted entry. - private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemory password) + private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan password) { if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); @@ -1082,7 +1082,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor compressedStream.Seek(-saltSize, SeekOrigin.Current); // Derive key material from the provided password - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.Span, salt, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); return WinZipAesStream.Create( baseStream: compressedStream, @@ -1123,14 +1123,14 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) return uncompressedStream; } - private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password = default) + private Stream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = default) { if (checkOpenable) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } - private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlySpan password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); Stream streamToDecompress; @@ -1150,7 +1150,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read return decompressedStream; } - private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) + private WrappedStream OpenInWriteMode(ReadOnlySpan password = default, EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_everOpenedForWrite) throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); @@ -1161,7 +1161,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod return OpenInWriteModeCore(password, encryptionMethod); } - private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) + private WrappedStream OpenInWriteModeCore(ReadOnlySpan password = default, EncryptionMethod encryptionMethod = EncryptionMethod.None) { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; @@ -1172,14 +1172,14 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet if (encryptionMethod == EncryptionMethod.ZipCrypto) { - if (string.IsNullOrEmpty(password)) + if (password.IsEmpty) { throw new ArgumentException(SR.EmptyPassword, nameof(password)); } Encryption = encryptionMethod; - ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); targetStream = ZipCryptoStream.Create( @@ -1198,7 +1198,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } - if (string.IsNullOrEmpty(password)) + if (password.IsEmpty) throw new ArgumentException(SR.EmptyPassword, nameof(password)); Encryption = encryptionMethod; @@ -1206,7 +1206,7 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet int keySizeBits = GetAesKeySizeBits(encryptionMethod); // Derive key material from password with new random salt - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.AsSpan(), salt: null, keySizeBits); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); targetStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, @@ -1236,13 +1236,13 @@ private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMet return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, string? password = null) + private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, ReadOnlySpan password = default) { if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); // Validate password requirement for encrypted entries - if (loadExistingContent && IsEncrypted && string.IsNullOrEmpty(password)) + if (loadExistingContent && IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); if (loadExistingContent) @@ -1259,7 +1259,7 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, string? // For encrypted entries, set up key material for re-encryption if (IsEncrypted) { - SetupEncryptionKeyMaterial(password!); + SetupEncryptionKeyMaterial(password); } } else @@ -1296,12 +1296,12 @@ internal void MarkAsModified() /// /// Sets up encryption key material for re-encryption when writing back to the archive. /// - private void SetupEncryptionKeyMaterial(string password) + private void SetupEncryptionKeyMaterial(ReadOnlySpan password) { // Derive and save key material for re-encryption if (IsZipCryptoEncrypted()) { - _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); Encryption = EncryptionMethod.ZipCrypto; } else if (UseAesEncryption()) @@ -1313,7 +1313,7 @@ private void SetupEncryptionKeyMaterial(string password) // Generate new salt and derive key material for AES // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(Encryption); - _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password.AsSpan(), salt: null, keySizeBits); + _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); // Encryption is already set from constructor (parsed from central directory AES extra field) } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 56f7846bae8fe9..3cabbec717195e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -118,7 +118,7 @@ private ZipCryptoStream(Stream baseStream, // Creates the persisted key material from a password. // Returns a struct of 3 integers to keep the key off the heap. - internal static ZipCryptoKeys CreateKey(ReadOnlyMemory password) + internal static ZipCryptoKeys CreateKey(ReadOnlySpan password) { // Initialize keys with standard ZipCrypto initial values uint key0 = 305419896; @@ -130,7 +130,7 @@ internal static ZipCryptoKeys CreateKey(ReadOnlyMemory password) const int SegmentSize = 32; Span buf = stackalloc byte[SegmentSize]; - ReadOnlySpan pwSpan = password.Span; + ReadOnlySpan pwSpan = password; while (!pwSpan.IsEmpty) { From eefb098d54c4034f701ffc3394dd06948bb58c98 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 1 Apr 2026 15:13:12 +0200 Subject: [PATCH 55/83] update apis to match ones discussed in api review --- .../ref/System.IO.Compression.ZipFile.cs | 62 ++-- .../src/System.IO.Compression.ZipFile.csproj | 2 + .../IO/Compression/ZipExtractionOptions.cs | 27 ++ .../IO/Compression/ZipFile.Create.Async.cs | 87 ++++++ .../System/IO/Compression/ZipFile.Create.cs | 76 +++++ .../IO/Compression/ZipFile.Extract.Async.cs | 289 +++--------------- .../System/IO/Compression/ZipFile.Extract.cs | 261 +++------------- .../IO/Compression/ZipFileCreationOptions.cs | 37 +++ ...pFileExtensions.ZipArchive.Create.Async.cs | 12 +- .../ZipFileExtensions.ZipArchive.Create.cs | 30 +- ...FileExtensions.ZipArchive.Extract.Async.cs | 78 +---- .../ZipFileExtensions.ZipArchive.Extract.cs | 75 +---- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 18 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 31 +- .../tests/ZipFile.Encryption.cs | 239 ++++++++------- .../tests/ZipFile.Extract.cs | 186 ++++++----- .../ref/System.IO.Compression.cs | 22 +- .../src/System.IO.Compression.csproj | 2 +- .../src/System/IO/Compression/ZipArchive.cs | 45 +++ .../IO/Compression/ZipArchiveEntry.Async.cs | 89 +----- .../System/IO/Compression/ZipArchiveEntry.cs | 162 ++++------ ...yptionMethod.cs => ZipEncryptionMethod.cs} | 2 +- .../tests/WinZipAesStreamConformanceTests.cs | 20 +- .../tests/ZipCryptoStreamConformanceTests.cs | 25 +- .../ZipCryptoStreamWrappedConformanceTests.cs | 41 ++- 25 files changed, 824 insertions(+), 1094 deletions(-) create mode 100644 src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipExtractionOptions.cs create mode 100644 src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileCreationOptions.cs rename src/libraries/System.IO.Compression/src/System/IO/Compression/{EncryptionMethod.cs => ZipEncryptionMethod.cs} (96%) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index b13263a9ee2069..1efc88fcc77748 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -6,50 +6,49 @@ namespace System.IO.Compression { + public sealed partial class ZipExtractionOptions + { + public ZipExtractionOptions() { } + public System.Text.Encoding? EntryNameEncoding { get { throw null; } set { } } + public bool OverwriteFiles { get { throw null; } set { } } + public System.ReadOnlyMemory Password { get { throw null; } set { } } + } public static partial class ZipFile { public static void CreateFromDirectory(string sourceDirectoryName, System.IO.Stream destination) { } public static void CreateFromDirectory(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory) { } public static void CreateFromDirectory(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Text.Encoding? entryNameEncoding) { } + public static void CreateFromDirectory(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.ZipFileCreationOptions options) { } public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName) { } public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory) { } public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Text.Encoding? entryNameEncoding) { } + public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.ZipFileCreationOptions options) { } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, System.IO.Stream destination, System.IO.Compression.ZipFileCreationOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, System.IO.Stream destination, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.CompressionLevel compressionLevel, bool includeBaseDirectory, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.IO.Compression.ZipFileCreationOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlySpan password) { } - public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlySpan password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.ReadOnlySpan password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlySpan password) { } - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlySpan password) { } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.IO.Compression.ZipArchive Open(string archiveFileName, System.IO.Compression.ZipArchiveMode mode) { throw null; } @@ -59,32 +58,37 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti public static System.IO.Compression.ZipArchive OpenRead(string archiveFileName) { throw null; } public static System.Threading.Tasks.Task OpenReadAsync(string archiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } - [System.ComponentModel.EditorBrowsableAttribute(1)] + public sealed partial class ZipFileCreationOptions + { + public ZipFileCreationOptions() { } + public System.IO.Compression.CompressionLevel CompressionLevel { get { throw null; } set { } } + public System.IO.Compression.ZipEncryptionMethod EncryptionMethod { get { throw null; } set { } } + public System.Text.Encoding? EntryNameEncoding { get { throw null; } set { } } + public bool IncludeBaseDirectory { get { throw null; } set { } } + public System.ReadOnlyMemory Password { get { throw null; } set { } } + } + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } - public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryption) { throw null; } - public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryption) { throw null; } - public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlySpan password, System.IO.Compression.ZipEncryptionMethod encryption) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlySpan password, System.IO.Compression.ZipEncryptionMethod encryption) { throw null; } + public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlyMemory password, System.IO.Compression.ZipEncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.ReadOnlyMemory password, System.IO.Compression.ZipEncryptionMethod encryption, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles) { } - public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlySpan password) { } - public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.ReadOnlySpan password) { } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options) { } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.IO.Compression.ZipExtractionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } - public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.ReadOnlySpan password) { } - public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.ReadOnlySpan password) { } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.IO.Compression.ZipExtractionOptions options) { } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.IO.Compression.ZipExtractionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj index beeaedeba67337..2c2d4f2517ec0f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipExtractionOptions.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipExtractionOptions.cs new file mode 100644 index 00000000000000..c46b44a66a6924 --- /dev/null +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipExtractionOptions.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace System.IO.Compression; + +/// +/// Options for extracting entries from a zip archive. +/// +public sealed class ZipExtractionOptions +{ + /// + /// Gets or sets the password used to decrypt encrypted entries in the archive. + /// + public ReadOnlyMemory Password { get; set; } + + /// + /// Gets or sets the encoding to use when reading entry names and comments. + /// + public Encoding? EntryNameEncoding { get; set; } + + /// + /// Gets or sets a value indicating whether to overwrite existing files during extraction. + /// + public bool OverwriteFiles { get; set; } +} diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs index 61ca3d237d156c..bdc6f376600968 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs @@ -430,6 +430,52 @@ public static Task CreateFromDirectoryAsync(string sourceDirectoryName, Stream d CompressionLevel compressionLevel, bool includeBaseDirectory, Encoding? entryNameEncoding, CancellationToken cancellationToken = default) => DoCreateFromDirectoryAsync(sourceDirectoryName, destination, compressionLevel, includeBaseDirectory, entryNameEncoding, cancellationToken); + /// + /// Asynchronously creates a zip archive at the specified path containing the files and directories from the specified directory, + /// using the specified creation options. + /// + /// The path to the directory to be archived. + /// The path of the archive to be created. + /// The creation options including compression level, encryption, encoding, and whether to include the base directory. + /// The token to monitor for cancellation requests. + /// , , or is . + public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, ZipFileCreationOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + cancellationToken.ThrowIfCancellationRequested(); + + (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); + + ZipArchive archive = await OpenAsync(destinationArchiveFileName, ZipArchiveMode.Create, options.EntryNameEncoding, cancellationToken).ConfigureAwait(false); + await using (archive) + { + await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password, options.EncryptionMethod, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Asynchronously creates a zip archive in the specified stream containing the files and directories from the specified directory, + /// using the specified creation options. + /// + /// The path to the directory to be archived. + /// The stream where the zip archive is to be stored. + /// The creation options including compression level, encryption, encoding, and whether to include the base directory. + /// The token to monitor for cancellation requests. + /// , , or is . + public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, Stream destination, ZipFileCreationOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + cancellationToken.ThrowIfCancellationRequested(); + + sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); + + ZipArchive archive = await ZipArchive.CreateAsync(destination, ZipArchiveMode.Create, leaveOpen: true, options.EntryNameEncoding, cancellationToken).ConfigureAwait(false); + await using (archive) + { + await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password, options.EncryptionMethod, cancellationToken).ConfigureAwait(false); + } + } + private static async Task DoCreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, CompressionLevel? compressionLevel, bool includeBaseDirectory, Encoding? entryNameEncoding, CancellationToken cancellationToken) @@ -501,4 +547,45 @@ private static async Task CreateZipArchiveFromDirectoryAsync(string sourceDirect FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); } + + private static async Task CreateZipArchiveFromDirectoryAsync(string sourceDirectoryName, ZipArchive archive, + CompressionLevel compressionLevel, bool includeBaseDirectory, + ReadOnlyMemory password, ZipEncryptionMethod encryptionMethod, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + (bool directoryIsEmpty, string basePath, DirectoryInfo di, FileSystemEnumerable<(string, CreateEntryType)> fse) = + InitializeCreateZipArchiveFromDirectory(sourceDirectoryName, includeBaseDirectory); + + bool hasEncryption = !password.IsEmpty && encryptionMethod != ZipEncryptionMethod.None; + + foreach ((string fullPath, CreateEntryType type) in fse) + { + directoryIsEmpty = false; + + switch (type) + { + case CreateEntryType.File: + { + string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); + if (hasEncryption) + await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, password, encryptionMethod, cancellationToken).ConfigureAwait(false); + else + await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, cancellationToken).ConfigureAwait(false); + } + break; + case CreateEntryType.Directory: + if (ArchivingUtils.IsDirEmpty(fullPath)) + { + string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length), appendPathSeparator: true); + archive.CreateEntry(entryName); + } + break; + default: + throw new IOException(SR.Format(SR.ZipUnsupportedFile, fullPath)); + } + } + + FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs index cd6d977ae1c0d2..3e2c8fd65f4f3e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs @@ -400,6 +400,42 @@ public static void CreateFromDirectory(string sourceDirectoryName, Stream destin CompressionLevel compressionLevel, bool includeBaseDirectory, Encoding? entryNameEncoding) => DoCreateFromDirectory(sourceDirectoryName, destination, compressionLevel, includeBaseDirectory, entryNameEncoding); + /// + /// Creates a zip archive at the specified path containing the files and directories from the specified directory, + /// using the specified creation options. + /// + /// The path to the directory to be archived. + /// The path of the archive to be created. + /// The creation options including compression level, encryption, encoding, and whether to include the base directory. + /// , , or is . + public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, ZipFileCreationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); + + using ZipArchive archive = Open(destinationArchiveFileName, ZipArchiveMode.Create, options.EntryNameEncoding); + CreateZipArchiveFromDirectory(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password.Span, options.EncryptionMethod); + } + + /// + /// Creates a zip archive in the specified stream containing the files and directories from the specified directory, + /// using the specified creation options. + /// + /// The path to the directory to be archived. + /// The stream where the zip archive is to be stored. + /// The creation options including compression level, encryption, encoding, and whether to include the base directory. + /// , , or is . + public static void CreateFromDirectory(string sourceDirectoryName, Stream destination, ZipFileCreationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); + + using ZipArchive archive = new ZipArchive(destination, ZipArchiveMode.Create, leaveOpen: true, options.EntryNameEncoding); + CreateZipArchiveFromDirectory(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password.Span, options.EncryptionMethod); + } + private static void DoCreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, CompressionLevel? compressionLevel, bool includeBaseDirectory, Encoding? entryNameEncoding) @@ -461,6 +497,46 @@ private static void CreateZipArchiveFromDirectory(string sourceDirectoryName, Zi FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); } + private static void CreateZipArchiveFromDirectory(string sourceDirectoryName, ZipArchive archive, + CompressionLevel compressionLevel, bool includeBaseDirectory, + ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) + { + (bool directoryIsEmpty, string basePath, DirectoryInfo di, FileSystemEnumerable<(string, CreateEntryType)> fse) = + InitializeCreateZipArchiveFromDirectory(sourceDirectoryName, includeBaseDirectory); + + bool hasEncryption = !password.IsEmpty && encryptionMethod != ZipEncryptionMethod.None; + + foreach ((string fullPath, CreateEntryType type) in fse) + { + directoryIsEmpty = false; + + switch (type) + { + case CreateEntryType.File: + { + string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); + if (hasEncryption) + ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel, password, encryptionMethod); + else + ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel); + } + break; + case CreateEntryType.Directory: + if (ArchivingUtils.IsDirEmpty(fullPath)) + { + string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length), appendPathSeparator: true); + archive.CreateEntry(entryName); + } + break; + case CreateEntryType.Unsupported: + default: + throw new IOException(SR.Format(SR.ZipUnsupportedFile, fullPath)); + } + } + + FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); + } + private static FileStream GetFileStreamForOpen(ZipArchiveMode mode, string archiveFileName, bool useAsync) { // Check if the path is a directory before attempting to open, diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index f0e252bbc73c4e..9007dfd3516213 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -205,42 +205,6 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s } } - /// - /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// An asynchronous operation is cancelled. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); - /// /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. /// The specified directory must not exist. This method will create all subdirectories and the specified directory. @@ -272,42 +236,6 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The path to the archive on the file system that is to be extracted. /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); - - /// - /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// An asynchronous operation is cancelled. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. /// /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support @@ -333,66 +261,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The password used to decrypt the encrypted entries in the archive. /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); - - /// - /// Asynchronously extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// An asynchronous operation is cancelled. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// True to indicate overwrite. - /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. - /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support - /// UTF-8 encoding for entry names or comments.
- /// This value is used as follows:
- /// If entryNameEncoding is not specified (== null): - /// - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, - /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, - /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. - /// - /// If entryNameEncoding is specified (!= null): - /// - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, - /// use the specified entryNameEncoding in order to decode the entry name and comment. - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, - /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. - /// - /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, - /// otherwise an is thrown. - /// - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) + private static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -401,7 +270,14 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s ZipArchive archive = await OpenAsync(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding, cancellationToken).ConfigureAwait(false); await using (archive) { - await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + foreach (ZipArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!password.IsEmpty) + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + else + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); + } } } @@ -563,112 +439,6 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati } } - /// - /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - /// An asynchronous operation is cancelled. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password, cancellationToken); - - /// - /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, and optionally allows choosing if the files in the destination directory should be overwritten. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// to overwrite files; otherwise. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - /// An asynchronous operation is cancelled. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password, cancellationToken); - - /// - /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system and uses the specified character encoding for entry names. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// If is set to a value other than , entry names and comments are decoded according to the following rules: - /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. - /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// If is set to , entry names and comments are decoded according to the following rules: - /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. - /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. - /// -or- - /// is set to a Unicode encoding other than UTF-8. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - /// An asynchronous operation is cancelled. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password, cancellationToken); - /// /// Asynchronously extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, uses the specified character encoding for entry names, and optionally allows choosing if the files in the destination directory should be overwritten. /// @@ -708,7 +478,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// An archive entry was compressed by using a compression method that is not supported. /// An asynchronous operation is cancelled. /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) + private static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -721,7 +491,44 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati ZipArchive archive = await ZipArchive.CreateAsync(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); await using (archive) { - await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + foreach (ZipArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!password.IsEmpty) + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + else + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); + } } } + + /// + /// Asynchronously extracts all of the files in the specified archive to a directory on the file system using the specified options. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files. + /// The extraction options including password, encoding, and overwrite behavior. + /// The cancellation token to monitor for cancellation requests. + /// , , or is . + public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, ZipExtractionOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + return ExtractToDirectoryAsync(sourceArchiveFileName, destinationDirectoryName, options.EntryNameEncoding, options.OverwriteFiles, options.Password, cancellationToken); + } + + /// + /// Asynchronously extracts all of the files in the specified stream to a directory on the file system using the specified options. + /// + /// The stream containing the archive to extract. + /// The path to the directory in which to place the extracted files. + /// The extraction options including password, encoding, and overwrite behavior. + /// The cancellation token to monitor for cancellation requests. + /// , , or is . + public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, ZipExtractionOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + return ExtractToDirectoryAsync(source, destinationDirectoryName, options.EntryNameEncoding, options.OverwriteFiles, options.Password, cancellationToken); + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs index 50824ffe223518..c0c384a077c413 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs @@ -188,39 +188,6 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti } } - /// - /// Extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, ReadOnlySpan password) => - ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); - /// /// Extracts all of the files in the specified password-protected archive to a directory on the file system. /// The specified directory must not exist. This method will create all subdirectories and the specified directory. @@ -251,39 +218,6 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the archive on the file system that is to be extracted. /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. - /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) => - ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); - - /// - /// Extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. /// /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support @@ -307,68 +241,15 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// otherwise an is thrown. /// /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlySpan password) => - ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); - - /// - /// Extracts all of the files in the specified password-protected archive to a directory on the file system. - /// The specified directory must not exist. This method will create all subdirectories and the specified directory. - /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will - /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry - /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information - /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified - /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// sourceArchive or destinationDirectoryName is null. - /// sourceArchive or destinationDirectoryName specifies a path, file name, - /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, - /// and file names must be less than 260 characters. - /// The path specified by sourceArchive or destinationDirectoryName is invalid, - /// (for example, it is on an unmapped drive). - /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or - /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// sourceArchive or destinationDirectoryName is in an invalid format. - /// sourceArchive was not found. - /// The archive specified by sourceArchive: Is not a valid ZipArchive - /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method - /// that is not supported. - /// - /// The path to the archive on the file system that is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// True to indicate overwrite. - /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. - /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support - /// UTF-8 encoding for entry names or comments.
- /// This value is used as follows:
- /// If entryNameEncoding is not specified (== null): - /// - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, - /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, - /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. - /// - /// If entryNameEncoding is specified (!= null): - /// - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, - /// use the specified entryNameEncoding in order to decode the entry name and comment. - /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, - /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. - /// - /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, - /// otherwise an is thrown. - /// - /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) + private static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) { ArgumentNullException.ThrowIfNull(sourceArchiveFileName); using ZipArchive archive = Open(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding); - archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + foreach (ZipArchiveEntry entry in archive.Entries) + { + entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles, password); + } } /// @@ -512,103 +393,6 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles); } - /// - /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, ReadOnlySpan password) => - ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); - - /// - /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, and optionally allows choosing if the files in the destination directory should be overwritten. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// to overwrite files; otherwise. - /// The password used to decrypt the encrypted entries in the archive. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) => - ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); - - /// - /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system and uses the specified character encoding for entry names. - /// - /// The stream from which the zip archive is to be extracted. - /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. - /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. - /// The password used to decrypt the encrypted entries in the archive. - /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. - /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. - /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. - /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// If is set to a value other than , entry names and comments are decoded according to the following rules: - /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. - /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// If is set to , entry names and comments are decoded according to the following rules: - /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. - /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. - /// -or- - /// is set to a Unicode encoding other than UTF-8. - /// or is . - /// The specified path in exceeds the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. - /// -or- - /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) - /// -or- - /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . - /// The caller does not have the required permission to access the archive or the destination directory. - /// contains an invalid format. - /// The archive contained in the stream is not a valid zip archive. - /// -or- - /// An archive entry was not found or was corrupt. - /// -or- - /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, ReadOnlySpan password) => - ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); - /// /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, uses the specified character encoding for entry names, and optionally allows choosing if the files in the destination directory should be overwritten. /// @@ -645,7 +429,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// An archive entry was not found or was corrupt. /// -or- /// An archive entry was compressed by using a compression method that is not supported. - public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) + private static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan password) { ArgumentNullException.ThrowIfNull(source); if (!source.CanRead) @@ -654,7 +438,38 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory } using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding); - archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + foreach (ZipArchiveEntry entry in archive.Entries) + { + entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles, password); + } + } + + /// + /// Extracts all of the files in the specified archive to a directory on the file system using the specified options. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files. + /// The extraction options including password, encoding, and overwrite behavior. + /// , , or is . + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, ZipExtractionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, options.EntryNameEncoding, options.OverwriteFiles, options.Password.Span); + } + + /// + /// Extracts all of the files in the specified stream to a directory on the file system using the specified options. + /// + /// The stream containing the archive to extract. + /// The path to the directory in which to place the extracted files. + /// The extraction options including password, encoding, and overwrite behavior. + /// , , or is . + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, ZipExtractionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + ExtractToDirectory(source, destinationDirectoryName, options.EntryNameEncoding, options.OverwriteFiles, options.Password.Span); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileCreationOptions.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileCreationOptions.cs new file mode 100644 index 00000000000000..06ecdeb5203659 --- /dev/null +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileCreationOptions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace System.IO.Compression; + +/// +/// Options for creating a zip archive from a directory. +/// +public sealed class ZipFileCreationOptions +{ + /// + /// Gets or sets the password used to encrypt entries in the archive. + /// + public ReadOnlyMemory Password { get; set; } + + /// + /// Gets or sets the encryption method to use when creating encrypted entries. + /// + public ZipEncryptionMethod EncryptionMethod { get; set; } + + /// + /// Gets or sets the compression level to use when creating entries. + /// + public CompressionLevel CompressionLevel { get; set; } + + /// + /// Gets or sets the encoding to use for entry names. + /// + public Encoding? EntryNameEncoding { get; set; } + + /// + /// Gets or sets a value indicating whether to include the base directory name as a prefix in the entry names. + /// + public bool IncludeBaseDirectory { get; set; } +} diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs index d1dde656c7e975..cc258f91cd6d9a 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs @@ -72,7 +72,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, - string sourceFileName, string entryName, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => + string sourceFileName, string entryName, ReadOnlyMemory password, ZipEncryptionMethod encryption, CancellationToken cancellationToken = default) => DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, null, password, encryption, cancellationToken); /// @@ -136,7 +136,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel compressionLevel, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken = default) => + string sourceFileName, string entryName, CompressionLevel compressionLevel, ReadOnlyMemory password, ZipEncryptionMethod encryption, CancellationToken cancellationToken = default) => DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, compressionLevel, password, encryption, cancellationToken); internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, @@ -159,17 +159,15 @@ internal static async Task DoCreateEntryFromFileAsync(this ZipA } internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, - CompressionLevel? compressionLevel, ReadOnlyMemory password, EncryptionMethod encryption, CancellationToken cancellationToken) + CompressionLevel? compressionLevel, ReadOnlyMemory password, ZipEncryptionMethod encryption, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password.Span, encryption); await using (fs) { - Stream es = encryption != EncryptionMethod.None - ? await entry.OpenAsync(password, encryption, cancellationToken).ConfigureAwait(false) - : await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + Stream es = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); await using (es) { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index fd1429e236b5fc..52f11694b91955 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -77,7 +77,7 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); - public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, ReadOnlySpan password, EncryptionMethod encryption) => + public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, ReadOnlySpan password, ZipEncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, null, password, encryption); @@ -113,7 +113,7 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, s /// A wrapper for the newly created entry. public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel, - ReadOnlySpan password, EncryptionMethod encryption) => + ReadOnlySpan password, ZipEncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, @@ -134,16 +134,14 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, - ReadOnlySpan password, EncryptionMethod encryption) + ReadOnlySpan password, ZipEncryptionMethod encryption) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false, password, encryption); using (fs) { - using (Stream es = encryption != EncryptionMethod.None - ? entry.Open(password, encryption) - : entry.Open()) + using (Stream es = entry.Open()) { fs.CopyTo(es); } @@ -152,7 +150,7 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio return entry; } - private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync) + private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync, ReadOnlySpan password = default, ZipEncryptionMethod encryption = ZipEncryptionMethod.None) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); @@ -165,9 +163,19 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); - ZipArchiveEntry entry = compressionLevel.HasValue ? - destination.CreateEntry(entryName, compressionLevel.Value) : - destination.CreateEntry(entryName); + ZipArchiveEntry entry; + if (!password.IsEmpty && encryption != ZipEncryptionMethod.None) + { + entry = compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) + : destination.CreateEntry(entryName, password, encryption); + } + else + { + entry = compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value) + : destination.CreateEntry(entryName); + } DateTime lastWrite = File.GetLastWriteTime(sourceFileName); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index 60011a666e8a8c..ac4824dcdaf26e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -84,79 +84,25 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string } /// - /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. - /// This method will create all subdirectories and the specified directory if necessary. - /// If there is an error while extracting the archive, the archive will remain partially extracted. - /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the - /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime - /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// Asynchronously extracts all of the files in the archive to a directory on the file system using the specified options. /// - /// destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// destinationDirectoryName is null. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. - /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid - /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination - /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). - /// -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// destinationDirectoryName is in an invalid format. - /// An archive entry was not found or was corrupt. - /// -or- An archive entry has been compressed using a compression method that is not supported. - /// An asynchronous operation is cancelled. - /// The zip archive to extract files from. - /// The path to the directory on the file system. - /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. - /// Relative path information is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - ExtractToDirectoryAsync(source, destinationDirectoryName, overwriteFiles: false, password, cancellationToken); - - /// - /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. - /// This method will create all subdirectories and the specified directory if necessary. - /// If there is an error while extracting the archive, the archive will remain partially extracted. - /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the - /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime - /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// destinationDirectoryName is null. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. - /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid - /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination - /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). - /// -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// destinationDirectoryName is in an invalid format. - /// An archive entry was not found or was corrupt. - /// -or- An archive entry has been compressed using a compression method that is not supported. - /// An asynchronous operation is cancelled. - /// The zip archive to extract files from. - /// The path to the directory on the file system. - /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. - /// Relative path information is interpreted as relative to the current working directory. - /// True to indicate overwrite. - /// The password used to decrypt the encrypted entries in the archive. - /// The cancellation token to monitor for cancellation requests. - /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. - public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) + public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, ZipExtractionOptions options, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationDirectoryName); + ArgumentNullException.ThrowIfNull(options); + + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlyMemory password = options.Password; foreach (ZipArchiveEntry entry in source.Entries) { - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (!password.IsEmpty) + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, options.OverwriteFiles, password, cancellationToken).ConfigureAwait(false); + else + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, options.OverwriteFiles, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index 2bfd9978e31758..bc7156b056272e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -75,77 +75,26 @@ public static void ExtractToDirectory(this ZipArchive source, string destination } /// - /// Extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. - /// This method will create all subdirectories and the specified directory if necessary. - /// If there is an error while extracting the archive, the archive will remain partially extracted. - /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the - /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime - /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// - /// - /// destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// destinationDirectoryName is null. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. - /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid - /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination - /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). - /// -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// destinationDirectoryName is in an invalid format. - /// An archive entry was not found or was corrupt. - /// -or- An archive entry has been compressed using a compression method that is not supported. - /// The zip archive to extract files from. - /// The path to the directory on the file system. - /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. - /// Relative path information is interpreted as relative to the current working directory. - /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, ReadOnlySpan password) => - ExtractToDirectory(source, destinationDirectoryName, overwriteFiles: false, password: password); - - /// - /// Extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. - /// This method will create all subdirectories and the specified directory if necessary. - /// If there is an error while extracting the archive, the archive will remain partially extracted. - /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the - /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime - /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// Extracts all of the files in the archive to a directory on the file system using the specified options. /// - /// - /// destinationDirectoryName is a zero-length string, contains only whitespace, - /// or contains one or more invalid characters as defined by InvalidPathChars. - /// destinationDirectoryName is null. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. - /// The specified path is invalid, (for example, it is on an unmapped drive). - /// An archive entry's name is zero-length, contains only whitespace, or contains one or more invalid - /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination - /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). - /// -or- An archive entry has the same name as an already extracted entry from the same archive. - /// The caller does not have the required permission. - /// destinationDirectoryName is in an invalid format. - /// An archive entry was not found or was corrupt. - /// -or- An archive entry has been compressed using a compression method that is not supported. /// The zip archive to extract files from. - /// The path to the directory on the file system. - /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. - /// Relative path information is interpreted as relative to the current working directory. - /// True to indicate overwrite. - /// The password used to decrypt the encrypted entries in the archive. - public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, ReadOnlySpan password) + /// The path to the directory in which to place the extracted files. + /// The extraction options including password, encoding, and overwrite behavior. + /// , , or is . + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, ZipExtractionOptions options) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(destinationDirectoryName); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } + ArgumentNullException.ThrowIfNull(options); + + ReadOnlySpan password = options.Password.Span; foreach (ZipArchiveEntry entry in source.Entries) { - entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles, password); + if (!password.IsEmpty) + entry.ExtractRelativeToDirectory(destinationDirectoryName, options.OverwriteFiles, password); + else + entry.ExtractRelativeToDirectory(destinationDirectoryName, options.OverwriteFiles); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index d603e848f5a3bb..08b98ff6c98d67 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -118,10 +118,20 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string } } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, ReadOnlyMemory password, CancellationToken cancellationToken = default) => - await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); + /// + /// Asynchronously creates a file on the file system with the entry's contents using the specified extraction options. + /// + public static Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, ZipExtractionOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + if (!options.Password.IsEmpty) + return ExtractToFileAsync(source, destinationFileName, options.OverwriteFiles, options.Password, cancellationToken); + else + return ExtractToFileAsync(source, destinationFileName, options.OverwriteFiles, cancellationToken); + } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) + private static async Task ExtractToFileAsync(ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) { if (password.IsEmpty) { @@ -196,7 +206,7 @@ internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, password: password, cancellationToken).ConfigureAwait(false); + await ExtractToFileAsync(source, fileDestinationPath, overwrite, password, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 37913814fc046f..97dc054d7dc2cc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -106,26 +106,23 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination } /// - /// Creates a file on the file system with the entry's contents decrypted using the specified password. - /// The last write time of the file is set to the entry's last write time. - /// This method does not allow overwriting of an existing file with the same name. + /// Creates a file on the file system with the entry's contents using the specified extraction options. /// /// The zip archive entry to extract a file from. /// The name of the file that will hold the contents of the entry. - /// The password used to decrypt the encrypted entry. - public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, ReadOnlySpan password) => - ExtractToFile(source, destinationFileName, overwrite: false, password: password); + /// The extraction options including password and overwrite behavior. + /// , , or is . + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, ZipExtractionOptions options) + { + ArgumentNullException.ThrowIfNull(options); - /// - /// Creates a file on the file system with the entry's contents decrypted using the specified password. - /// The last write time of the file is set to the entry's last write time. - /// This method allows overwriting of an existing file with the same name. - /// - /// The zip archive entry to extract a file from. - /// The name of the file that will hold the contents of the entry. - /// True to indicate overwrite. - /// The password used to decrypt the encrypted entry. - public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlySpan password) + if (!options.Password.IsEmpty) + ExtractToFile(source, destinationFileName, options.OverwriteFiles, options.Password.Span); + else + ExtractToFile(source, destinationFileName, options.OverwriteFiles); + } + + private static void ExtractToFile(ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlySpan password) { if (password.IsEmpty) { @@ -245,7 +242,7 @@ internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, str // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); if (!password.IsEmpty) - source.ExtractToFile(fileDestinationPath, overwrite: overwrite, password: password); + ExtractToFile(source, fileDestinationPath, overwrite, password); else source.ExtractToFile(fileDestinationPath, overwrite: overwrite); } diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 1e7addfa7d6521..238833900c9af2 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -14,10 +14,10 @@ public static IEnumerable EncryptionMethodAndBoolTestData() { foreach (var method in new[] { - EncryptionMethod.ZipCrypto, - EncryptionMethod.Aes128, - EncryptionMethod.Aes192, - EncryptionMethod.Aes256 + ZipEncryptionMethod.ZipCrypto, + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.Aes256 }) { yield return new object[] { method, false }; @@ -28,14 +28,14 @@ public static IEnumerable EncryptionMethodAndBoolTestData() [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task Encryption_SingleEntry_RoundTrip(EncryptionMethod encryptionMethod, bool async) + public async Task Encryption_SingleEntry_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string entryName = "test.txt"; string content = "Secret Content"; string password = "password123"; - var entries = new[] { (entryName, content, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { (entryName, content, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -51,14 +51,14 @@ public async Task Encryption_SingleEntry_RoundTrip(EncryptionMethod encryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(EncryptionMethod encryptionMethod, bool async) + public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "SharedPassword"; var entries = new[] { - ("file1.txt", "Content 1", (string?)password, (EncryptionMethod?)encryptionMethod), - ("folder/file2.txt", "Content 2", (string?)password, (EncryptionMethod?)encryptionMethod) + ("file1.txt", "Content 1", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("folder/file2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -77,13 +77,13 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(EncryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(EncryptionMethod encryptionMethod, bool async) + public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); var entries = new[] { - ("plain.txt", "Plain Content", (string?)null, (EncryptionMethod?)null), - ("encrypted.txt", "Encrypted Content", (string?)"pass", (EncryptionMethod?)encryptionMethod) + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)"pass", (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -110,9 +110,9 @@ public async Task Encryption_Combinations_RoundTrip(bool async) string archivePath = GetTempArchivePath(); var entries = new[] { - ("zipcrypto.txt", "ZipCrypto Content", (string?)"pass1", (EncryptionMethod?)EncryptionMethod.ZipCrypto), - ("aes128.txt", "AES128 Content", (string?)"pass2", (EncryptionMethod?)EncryptionMethod.Aes128), - ("aes256.txt", "AES256 Content", (string?)"pass3", (EncryptionMethod?)EncryptionMethod.Aes256) + ("zipcrypto.txt", "ZipCrypto Content", (string?)"pass1", (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto), + ("aes128.txt", "AES128 Content", (string?)"pass2", (ZipEncryptionMethod?)ZipEncryptionMethod.Aes128), + ("aes256.txt", "AES256 Content", (string?)"pass3", (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -139,12 +139,12 @@ public async Task Encryption_LargeFile_RoundTrip(bool async) byte[] content = new byte[size]; new Random(42).NextBytes(content); string password = "password123"; - var encryptionMethod = EncryptionMethod.Aes256; + var encryptionMethod = ZipEncryptionMethod.Aes256; using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName); - Stream s = entry.Open(password, encryptionMethod); + ZipArchiveEntry entry = archive.CreateEntry(entryName, password, encryptionMethod); + Stream s = entry.Open(); using (s) { if (async) @@ -179,7 +179,7 @@ public void WrongPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.OpenRead(archivePath)) @@ -194,7 +194,7 @@ public void MissingPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.OpenRead(archivePath)) @@ -209,7 +209,7 @@ public void MissingPassword_Throws_InvalidDataException() public async Task OpeningPlainEntryWithPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); - var entries = new[] { ("plain.txt", "content", (string?)null, (EncryptionMethod?)null) }; + var entries = new[] { ("plain.txt", "content", (string?)null, (ZipEncryptionMethod?)null) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -227,7 +227,7 @@ public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); string password = "pass"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -237,12 +237,12 @@ public async Task ExtractToFile_Encrypted_Success(bool async) if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: true, password: password.AsMemory()); + await entry.ExtractToFileAsync(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); Assert.Equal("content", await File.ReadAllTextAsync(destFile)); } else { - entry.ExtractToFile(destFile, overwrite: true, password: password); + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); Assert.Equal("content", File.ReadAllText(destFile)); } } @@ -250,22 +250,22 @@ public async Task ExtractToFile_Encrypted_Success(bool async) private string GetTempArchivePath() => GetTestFilePath(); - private async Task CreateArchiveWithEntries(string archivePath, (string Name, string Content, string? Password, EncryptionMethod? Encryption)[] entries, bool async) + private async Task CreateArchiveWithEntries(string archivePath, (string Name, string Content, string? Password, ZipEncryptionMethod? Encryption)[] entries, bool async) { using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { foreach (var (name, content, password, encryption) in entries) { - ZipArchiveEntry entry = archive.CreateEntry(name); - Stream s; + ZipArchiveEntry entry; if (password != null && encryption.HasValue) { - s = entry.Open(password, encryption.Value); + entry = archive.CreateEntry(name, password, encryption.Value); } else { - s = await OpenEntryStream(async, entry); + entry = archive.CreateEntry(name); } + Stream s = await OpenEntryStream(async, entry); using (s) using (StreamWriter w = new StreamWriter(s, Encoding.UTF8)) @@ -309,7 +309,7 @@ private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(EncryptionMethod encryptionMethod, bool async) + public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string entryName = "test.txt"; @@ -318,7 +318,7 @@ public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(EncryptionMethod enc string password = "password123"; // Create archive with encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { (entryName, originalContent, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); // Verify original content @@ -363,7 +363,7 @@ public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(EncryptionMethod enc [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(EncryptionMethod encryptionMethod, bool async) + public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string entryName = "test.txt"; @@ -371,7 +371,7 @@ public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(EncryptionMet string password = "password123"; // Create archive with encrypted entry - var entries = new[] { (entryName, content, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { (entryName, content, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); // Open in Update mode, read the entry but don't modify it @@ -405,13 +405,13 @@ public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) { string archivePath = GetTempArchivePath(); string password = "password123"; - var encryptionMethod = EncryptionMethod.Aes256; + var encryptionMethod = ZipEncryptionMethod.Aes256; var entries = new[] { - ("file1.txt", "Content 1", (string?)password, (EncryptionMethod?)encryptionMethod), - ("file2.txt", "Content 2", (string?)password, (EncryptionMethod?)encryptionMethod), - ("file3.txt", "Content 3", (string?)password, (EncryptionMethod?)encryptionMethod) + ("file1.txt", "Content 1", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("file3.txt", "Content 3", (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -452,8 +452,8 @@ public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) var entries = new[] { - ("plain.txt", "Plain Content", (string?)null, (EncryptionMethod?)null), - ("encrypted.txt", "Encrypted Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -507,13 +507,13 @@ public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) new Random(42).NextBytes(originalContent); new Random(43).NextBytes(modifiedContent); string password = "password123"; - var encryptionMethod = EncryptionMethod.Aes256; + var encryptionMethod = ZipEncryptionMethod.Aes256; // Create archive with large encrypted entry using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName); - using (Stream s = entry.Open(password, encryptionMethod)) + ZipArchiveEntry entry = archive.CreateEntry(entryName, password, encryptionMethod); + using (Stream s = entry.Open()) { if (async) await s.WriteAsync(originalContent, 0, originalContent.Length); @@ -561,7 +561,7 @@ public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(EncryptionMethod encryptionMethod, bool async) + public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string entryName = "test.txt"; @@ -569,7 +569,7 @@ public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(EncryptionMet string password = "password123"; // Create archive with encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { (entryName, originalContent, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); // Open in Update mode and clear the content @@ -600,7 +600,7 @@ public void UpdateMode_EncryptedEntry_WrongPassword_Throws() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) @@ -616,7 +616,7 @@ public void UpdateMode_EncryptedEntry_NoPassword_Throws() { string archivePath = GetTempArchivePath(); string password = "correct"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) @@ -638,9 +638,9 @@ public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) var entries = new[] { - ("keep.txt", "Keep This Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256), - ("delete.txt", "Delete This Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256), - ("modify.txt", "Original Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) + ("keep.txt", "Keep This Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("delete.txt", "Delete This Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("modify.txt", "Original Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -705,8 +705,8 @@ public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) var entries = new[] { - ("plain.txt", "Plain Content", (string?)null, (EncryptionMethod?)null), - ("encrypted.txt", "Encrypted Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -756,8 +756,8 @@ public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) var entries = new[] { - ("plain.txt", "Plain Content", (string?)null, (EncryptionMethod?)null), - ("encrypted.txt", "Encrypted Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -807,11 +807,11 @@ public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) var entries = new[] { - ("keep1.txt", "Keep 1", (string?)null, (EncryptionMethod?)null), - ("delete1.txt", "Delete 1", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256), - ("keep2.txt", "Keep 2", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto), - ("delete2.txt", "Delete 2", (string?)null, (EncryptionMethod?)null), - ("modify.txt", "Original", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes128) + ("keep1.txt", "Keep 1", (string?)null, (ZipEncryptionMethod?)null), + ("delete1.txt", "Delete 1", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("keep2.txt", "Keep 2", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto), + ("delete2.txt", "Delete 2", (string?)null, (ZipEncryptionMethod?)null), + ("modify.txt", "Original", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes128) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -852,7 +852,7 @@ public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(EncryptionMethod encryptionMethod, bool async) + public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "password123"; @@ -860,9 +860,9 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(EncryptionMethod // Create archive with multiple entries using the same encryption method var entries = new[] { - ("entry1.txt", "Content 1", (string?)password, (EncryptionMethod?)encryptionMethod), - ("entry2.txt", "Content 2", (string?)password, (EncryptionMethod?)encryptionMethod), - ("entry3.txt", "Content 3", (string?)password, (EncryptionMethod?)encryptionMethod) + ("entry1.txt", "Content 1", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("entry2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("entry3.txt", "Content 3", (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -916,11 +916,11 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression // Create archive with entries using different AES strengths var entries = new[] { - ("aes128.txt", "AES-128 content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes128), - ("aes192.txt", "AES-192 content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes192), - ("aes256.txt", "AES-256 content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256), - ("zipcrypto.txt", "ZipCrypto content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto), - ("plain.txt", "Plain content", (string?)null, (EncryptionMethod?)null) + ("aes128.txt", "AES-128 content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes128), + ("aes192.txt", "AES-192 content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes192), + ("aes256.txt", "AES-256 content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("zipcrypto.txt", "ZipCrypto content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto), + ("plain.txt", "Plain content", (string?)null, (ZipEncryptionMethod?)null) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -948,7 +948,7 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression [Theory] [SkipOnCI("Takes significant time and disk space to create 4GB+ files")] [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(EncryptionMethod encryptionMethod, bool async) + public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -975,8 +975,8 @@ public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(EncryptionMethod enc // Create archive using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); - using (Stream s = entry.Open(password, encryptionMethod)) + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression, password, encryptionMethod); + using (Stream s = entry.Open()) { long written = 0; while (written < size) @@ -1040,7 +1040,7 @@ public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(EncryptionMethod enc [Theory] [SkipOnCI("Takes significant time and disk space to create 4GB+ files")] [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(EncryptionMethod encryptionMethod, bool async) + public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1065,8 +1065,8 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(EncryptionMe using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); - using (Stream s = entry.Open(password, encryptionMethod)) + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression, password, encryptionMethod); + using (Stream s = entry.Open()) { long written = 0; while (written < size) @@ -1103,12 +1103,12 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(EncryptionMe [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encryptionMethod, bool async) + public async Task ExtractToFile_EncryptedEntry_Success(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "TestPassword123"; string content = "Encrypted content for ExtractToFile test"; - var entries = new[] { ("encrypted.txt", content, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -1121,12 +1121,12 @@ public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encrypti if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: false, password: password.AsMemory()); + await entry.ExtractToFileAsync(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); Assert.Equal(content, await File.ReadAllTextAsync(destFile)); } else { - entry.ExtractToFile(destFile, overwrite: false, password: password); + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); Assert.Equal(content, File.ReadAllText(destFile)); } } @@ -1135,12 +1135,12 @@ public async Task ExtractToFile_EncryptedEntry_Success(EncryptionMethod encrypti [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(EncryptionMethod encryptionMethod, bool async) + public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "TestPassword123"; string content = "Updated encrypted content"; - var entries = new[] { ("encrypted.txt", content, (string?)password, (EncryptionMethod?)encryptionMethod) }; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); string destFile = GetTestFilePath(); @@ -1154,12 +1154,12 @@ public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(EncryptionMetho if (async) { - await entry.ExtractToFileAsync(destFile, overwrite: true, password: password.AsMemory()); + await entry.ExtractToFileAsync(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); Assert.Equal(content, await File.ReadAllTextAsync(destFile)); } else { - entry.ExtractToFile(destFile, overwrite: true, password: password); + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); Assert.Equal(content, File.ReadAllText(destFile)); } } @@ -1172,7 +1172,7 @@ public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); string password = "CorrectPassword"; - var entries = new[] { ("encrypted.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("encrypted.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -1183,11 +1183,11 @@ public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) if (async) { - await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, overwrite: false, password: "WrongPassword".AsMemory())); + await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = "WrongPassword".AsMemory() })); } else { - Assert.Throws(() => entry.ExtractToFile(destFile, overwrite: false, password: "WrongPassword")); + Assert.Throws(() => entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = "WrongPassword".AsMemory() })); } } } @@ -1199,16 +1199,16 @@ public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Success(EncryptionMethod encryptionMethod, bool async) + public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Success(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "SharedPassword"; var entries = new[] { - ("file1.txt", "Content 1", (string?)password, (EncryptionMethod?)encryptionMethod), - ("file2.txt", "Content 2", (string?)password, (EncryptionMethod?)encryptionMethod), - ("subfolder/file3.txt", "Content 3", (string?)password, (EncryptionMethod?)encryptionMethod), - ("subfolder/nested/file4.txt", "Content 4", (string?)password, (EncryptionMethod?)encryptionMethod) + ("file1.txt", "Content 1", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("subfolder/file3.txt", "Content 3", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("subfolder/nested/file4.txt", "Content 4", (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -1218,11 +1218,11 @@ public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Succe { if (async) { - await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password.AsMemory()); + await archive.ExtractToDirectoryAsync(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); } else { - archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password); + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); } } @@ -1244,9 +1244,9 @@ public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(b // Create entries with different passwords var entries = new[] { - ("file1.txt", "Content 1", (string?)"Password1", (EncryptionMethod?)EncryptionMethod.Aes256), - ("file2.txt", "Content 2", (string?)"Password2", (EncryptionMethod?)EncryptionMethod.Aes256), - ("file3.txt", "Content 3", (string?)"Password3", (EncryptionMethod?)EncryptionMethod.Aes256) + ("file1.txt", "Content 1", (string?)"Password1", (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("file2.txt", "Content 2", (string?)"Password2", (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256), + ("file3.txt", "Content 3", (string?)"Password3", (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -1258,12 +1258,12 @@ public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(b if (async) { await Assert.ThrowsAsync(() => - archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, "Password1".AsMemory())); + archive.ExtractToDirectoryAsync(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = "Password1".AsMemory() })); } else { Assert.Throws(() => - archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, "Password1")); + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = "Password1".AsMemory() })); } } } @@ -1271,14 +1271,14 @@ await Assert.ThrowsAsync(() => [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(EncryptionMethod encryptionMethod, bool async) + public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string password = "TestPassword"; var entries = new[] { - ("file1.txt", "New Content 1", (string?)password, (EncryptionMethod?)encryptionMethod), - ("file2.txt", "New Content 2", (string?)password, (EncryptionMethod?)encryptionMethod) + ("file1.txt", "New Content 1", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("file2.txt", "New Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -1292,11 +1292,11 @@ public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(EncryptionMe { if (async) { - await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: true, password.AsMemory()); + await archive.ExtractToDirectoryAsync(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); } else { - archive.ExtractToDirectory(tempDir.Path, overwriteFiles: true, password); + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); } } @@ -1314,7 +1314,7 @@ public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Thro string password = "TestPassword"; var entries = new[] { - ("file1.txt", "New Content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) + ("file1.txt", "New Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); @@ -1328,12 +1328,12 @@ public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Thro if (async) { await Assert.ThrowsAsync(() => - archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password.AsMemory())); + archive.ExtractToDirectoryAsync(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() })); } else { Assert.Throws(() => - archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password)); + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() })); } } @@ -1351,7 +1351,7 @@ public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) { string archivePath = GetTempArchivePath(); string password = "secret"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.ZipCrypto) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) @@ -1388,14 +1388,14 @@ public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) // Read access in create mode throws await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password".AsMemory())); // Encryption without password throws - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, null!, EncryptionMethod.Aes256)); - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, "".AsMemory(), EncryptionMethod.Aes256)); + Assert.Throws(() => archive.CreateEntry("test_null.txt", (string)null!, ZipEncryptionMethod.Aes256)); + Assert.Throws(() => archive.CreateEntry("test_empty.txt", "", ZipEncryptionMethod.Aes256)); } else { Assert.Throws(() => entry.Open(FileAccess.Read, "password")); - Assert.Throws(() => entry.Open(FileAccess.Write, null!, EncryptionMethod.Aes256)); - Assert.Throws(() => entry.Open(FileAccess.Write, "", EncryptionMethod.Aes256)); + Assert.Throws(() => archive.CreateEntry("test_null.txt", (string)null!, ZipEncryptionMethod.Aes256)); + Assert.Throws(() => archive.CreateEntry("test_empty.txt", "", ZipEncryptionMethod.Aes256)); } } } @@ -1407,7 +1407,7 @@ public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bo { string archivePath = GetTempArchivePath(); string password = "secret"; - var entries = new[] { ("test.txt", "content", (string?)password, (EncryptionMethod?)EncryptionMethod.Aes256) }; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.Aes256) }; await CreateArchiveWithEntries(archivePath, entries, async); using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) @@ -1441,7 +1441,7 @@ public static IEnumerable CreateEntryFromFile_Encrypted_TestData() { foreach (var row in EncryptionMethodAndBoolTestData()) { - var method = (EncryptionMethod)row[0]; + var method = (ZipEncryptionMethod)row[0]; var async = (bool)row[1]; yield return new object[] { method, false, async }; // no compression overload @@ -1452,7 +1452,7 @@ public static IEnumerable CreateEntryFromFile_Encrypted_TestData() [Theory] [MemberData(nameof(CreateEntryFromFile_Encrypted_TestData))] [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] - public async Task CreateEntryFromFile_Encrypted_RoundTrip(EncryptionMethod encryptionMethod, bool useCompression, bool async) + public async Task CreateEntryFromFile_Encrypted_RoundTrip(ZipEncryptionMethod encryptionMethod, bool useCompression, bool async) { string archivePath = GetTempArchivePath(); string sourcePath = GetTestFilePath(); @@ -1501,9 +1501,9 @@ public static IEnumerable AesEncryptionMethodAndBoolTestData() { foreach (var method in new[] { - EncryptionMethod.Aes128, - EncryptionMethod.Aes192, - EncryptionMethod.Aes256 + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.Aes256 }) { yield return new object[] { method, false }; @@ -1523,8 +1523,8 @@ public async Task Browser_ZipCrypto_Encryption_Works(bool async) using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName); - using (Stream s = entry.Open(password, EncryptionMethod.ZipCrypto)) + ZipArchiveEntry entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using (Stream s = entry.Open()) using (StreamWriter w = new StreamWriter(s, Encoding.UTF8)) { if (async) @@ -1546,7 +1546,7 @@ public async Task Browser_ZipCrypto_Encryption_Works(bool async) [Theory] [MemberData(nameof(AesEncryptionMethodAndBoolTestData))] [PlatformSpecific(TestPlatforms.Browser)] - public async Task Browser_AesEncryption_Throws_PlatformNotSupportedException(EncryptionMethod encryptionMethod, bool async) + public async Task Browser_AesEncryption_Throws_PlatformNotSupportedException(ZipEncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); string entryName = "test.txt"; @@ -1554,8 +1554,7 @@ public async Task Browser_AesEncryption_Throws_PlatformNotSupportedException(Enc using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) { - ZipArchiveEntry entry = archive.CreateEntry(entryName); - Assert.Throws(() => entry.Open(password, encryptionMethod)); + Assert.Throws(() => archive.CreateEntry(entryName, password, encryptionMethod)); } } diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 78037c372726ac..fa4a51815f962a 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; @@ -258,7 +258,7 @@ public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); // Act: Extract using password - entry.ExtractToFile(tempFile, overwrite: true, password: CorrectPassword); + entry.ExtractToFile(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = CorrectPassword.AsMemory() }); // Assert: File exists and content matches expected plaintext Assert.True(File.Exists(tempFile), "Extracted file was not created."); @@ -284,7 +284,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() Assert.Throws(() => { - entry.ExtractToFile(tempFile, overwrite: true, password: "wrongpass"); + entry.ExtractToFile(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "wrongpass".AsMemory() }); }); } @@ -321,7 +321,7 @@ public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789".AsMemory()); + await entry.ExtractToFileAsync(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "123456789".AsMemory() }); Assert.True(File.Exists(tempFile), "Extracted file was not created."); string content = await File.ReadAllTextAsync(tempFile); @@ -345,7 +345,7 @@ public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() await Assert.ThrowsAsync(async () => { - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "wrongpass".AsMemory()); + await entry.ExtractToFileAsync(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "wrongpass".AsMemory() }); }); } @@ -472,9 +472,9 @@ public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { // Your custom overload that sets per-entry password & ZipCrypto - var entry = za.CreateEntry(entryName); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var writer = new StreamWriter(entry.Open(password, EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); writer.Write(expectedContent); } @@ -509,15 +509,15 @@ public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() ("c/readme.md", "# readme"), }; const string password = "S@m3PW!"; - const EncryptionMethod enc = EncryptionMethod.ZipCrypto; + const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; // Act 1: Create with same password for all using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { foreach (var it in items) { - var entry = za.CreateEntry(it.Name); - using var w = new StreamWriter(entry.Open(password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name, password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -551,15 +551,15 @@ public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() ("e/info.txt", "echo-info", "pw-e"), ("f/sub/notes.txt", "foxtrot-notes", "pw-f"), }; - const EncryptionMethod enc = EncryptionMethod.ZipCrypto; + const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; // Act 1: Create, each entry with its own password using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { foreach (var it in items) { - var entry = za.CreateEntry(it.Name); - using var w = new StreamWriter(entry.Open(it.Password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name, it.Password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -598,7 +598,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() if (File.Exists(zipPath)) File.Delete(zipPath); const string encPw = "EncOnly123!"; - const EncryptionMethod enc = EncryptionMethod.ZipCrypto; + const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; var encryptedItems = new (string Name, string Content)[] { @@ -618,8 +618,8 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() // Encrypted foreach (var it in encryptedItems) { - var entry = za.CreateEntry(it.Name); - using var w = new StreamWriter(entry.Open(encPw, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name, encPw, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); w.Write(it.Content); } @@ -679,8 +679,8 @@ public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() // Act 1: Create archive with async write using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var entry = za.CreateEntry(entryName); - using var stream = entry.Open(password, EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using var stream = entry.Open(); byte[] data = Encoding.UTF8.GetBytes(expectedContent); await stream.WriteAsync(data, 0, data.Length); @@ -721,8 +721,8 @@ public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() // Act 1: Create with multiple async writes using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var entry = za.CreateEntry(entryName); - using var stream = entry.Open(password, EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using var stream = entry.Open(); foreach (var part in parts) { @@ -765,8 +765,8 @@ public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() // Act 1: Create entry using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var entry = za.CreateEntry(entryName); - using var writer = new StreamWriter(entry.Open(password, EncryptionMethod.ZipCrypto), Encoding.UTF8); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); await writer.WriteAsync(expectedContent); } @@ -805,15 +805,15 @@ public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { // Synchronous write - var syncEntry = za.CreateEntry(syncEntryName); - using (var syncWriter = new StreamWriter(syncEntry.Open(password, EncryptionMethod.ZipCrypto), Encoding.UTF8)) + var syncEntry = za.CreateEntry(syncEntryName, password, ZipEncryptionMethod.ZipCrypto); + using (var syncWriter = new StreamWriter(syncEntry.Open(), Encoding.UTF8)) { syncWriter.Write(syncContent); } // Asynchronous write - var asyncEntry = za.CreateEntry(asyncEntryName); - using (var asyncWriter = new StreamWriter(asyncEntry.Open(password, EncryptionMethod.ZipCrypto), Encoding.UTF8)) + var asyncEntry = za.CreateEntry(asyncEntryName, password, ZipEncryptionMethod.ZipCrypto); + using (var asyncWriter = new StreamWriter(asyncEntry.Open(), Encoding.UTF8)) { await asyncWriter.WriteAsync(asyncContent); } @@ -865,8 +865,8 @@ public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() // Act 1: Write large data asynchronously using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var entry = za.CreateEntry(entryName); - using var stream = entry.Open(password, EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using var stream = entry.Open(); // Write in 64KB chunks const int chunkSize = 65536; @@ -914,8 +914,8 @@ public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() // Act 1: Write using CopyToAsync using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var entry = za.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); + using var entryStream = entry.Open(); using var sourceStream = new MemoryStream(expectedData); await sourceStream.CopyToAsync(entryStream); @@ -1024,8 +1024,8 @@ public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); writer.Write(expectedContent); } @@ -1072,8 +1072,8 @@ public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() { foreach (var (name, content) in entries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(password, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(content); } @@ -1123,8 +1123,8 @@ public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() { foreach (var (name, content, pwd) in entries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(pwd, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(name, pwd, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(content); } @@ -1178,8 +1178,8 @@ public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() // Add encrypted entries foreach (var (name, content) in encryptedEntries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(password, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(content); } @@ -1239,8 +1239,8 @@ public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes128); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(expectedContent); } @@ -1278,8 +1278,8 @@ public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes192); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes192); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(expectedContent); } @@ -1309,13 +1309,13 @@ public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() Directory.CreateDirectory(DownloadsDir); string tempPath = Path.Join(DownloadsDir, "mixed_aes_levels.zip"); - var entries = new (string Name, string Content, string Password, EncryptionMethod Encryption)[] + var entries = new (string Name, string Content, string Password, ZipEncryptionMethod Encryption)[] { - ("aes128/file1.txt", "AES-128 encrypted content", "password128", EncryptionMethod.Aes128), - ("aes192/file2.txt", "AES-192 encrypted content", "password192", EncryptionMethod.Aes192), - ("aes256/file3.txt", "AES-256 encrypted content", "password256", EncryptionMethod.Aes256), - ("mixed/doc1.json", "{\"encryption\": \"AES-128\"}", "jsonPass128", EncryptionMethod.Aes128), - ("mixed/doc2.xml", "AES-192", "xmlPass192", EncryptionMethod.Aes192) + ("aes128/file1.txt", "AES-128 encrypted content", "password128", ZipEncryptionMethod.Aes128), + ("aes192/file2.txt", "AES-192 encrypted content", "password192", ZipEncryptionMethod.Aes192), + ("aes256/file3.txt", "AES-256 encrypted content", "password256", ZipEncryptionMethod.Aes256), + ("mixed/doc1.json", "{\"encryption\": \"AES-128\"}", "jsonPass128", ZipEncryptionMethod.Aes128), + ("mixed/doc2.xml", "AES-192", "xmlPass192", ZipEncryptionMethod.Aes192) }; // Act 1: Create ZIP with entries using different AES encryption levels @@ -1324,8 +1324,8 @@ public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() { foreach (var (name, content, pwd, encryption) in entries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(pwd, encryption); + var entry = archive.CreateEntry(name, pwd, encryption); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); writer.Write(content); } @@ -1371,8 +1371,8 @@ public void CreateLargeFileWithAES128_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes128); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); + using var entryStream = entry.Open(); entryStream.Write(largeContent); } @@ -1420,8 +1420,8 @@ public async Task CreateCompressedAndAES192Encrypted_RoundTrip() { foreach (var (name, content, level) in entries) { - var entry = archive.CreateEntry(name, level); - using var entryStream = entry.Open(password, EncryptionMethod.Aes192); + var entry = archive.CreateEntry(name, level, password, ZipEncryptionMethod.Aes192); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); writer.Write(content); } @@ -1458,22 +1458,22 @@ public async Task MixAllEncryptionTypes_RoundTrip() Directory.CreateDirectory(DownloadsDir); string tempPath = Path.Join(DownloadsDir, "all_encryption_types.zip"); - var entries = new (string Name, string Content, string? Password, EncryptionMethod? Encryption)[] + var entries = new (string Name, string Content, string? Password, ZipEncryptionMethod? Encryption)[] { // Plain entry ("plain/readme.txt", "This is a plain unencrypted file", null, null), // ZipCrypto - ("zipcrypto/secret.txt", "ZipCrypto encrypted content", "zipPass", EncryptionMethod.ZipCrypto), + ("zipcrypto/secret.txt", "ZipCrypto encrypted content", "zipPass", ZipEncryptionMethod.ZipCrypto), // AES-128 - ("aes128/data.txt", "AES-128 encrypted data", "aes128Pass", EncryptionMethod.Aes128), + ("aes128/data.txt", "AES-128 encrypted data", "aes128Pass", ZipEncryptionMethod.Aes128), // AES-192 - ("aes192/config.json", "{\"level\": \"AES-192\"}", "aes192Pass", EncryptionMethod.Aes192), + ("aes192/config.json", "{\"level\": \"AES-192\"}", "aes192Pass", ZipEncryptionMethod.Aes192), // AES-256 - ("aes256/secure.xml", "AES-256 secured", "aes256Pass", EncryptionMethod.Aes256) + ("aes256/secure.xml", "AES-256 secured", "aes256Pass", ZipEncryptionMethod.Aes256) }; // Act 1: Create ZIP with all encryption types @@ -1482,10 +1482,10 @@ public async Task MixAllEncryptionTypes_RoundTrip() { foreach (var (name, content, pwd, encryption) in entries) { - var entry = archive.CreateEntry(name); - Stream entryStream = pwd != null && encryption.HasValue - ? entry.Open(pwd, encryption.Value) - : entry.Open(); + var entry = pwd != null && encryption.HasValue + ? archive.CreateEntry(name, pwd, encryption.Value) + : archive.CreateEntry(name); + Stream entryStream = entry.Open(); using (entryStream) using (var writer = new StreamWriter(entryStream, Encoding.UTF8)) @@ -1563,8 +1563,8 @@ public async Task AES128WithSpecialCharacters_RoundTrip() { foreach (var (name, content) in entries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(password, EncryptionMethod.Aes128); + var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes128); + using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); await writer.WriteAsync(content); } @@ -1602,8 +1602,8 @@ public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); // Use async write operations byte[] contentBytes = Encoding.UTF8.GetBytes(expectedContent); @@ -1636,11 +1636,11 @@ public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() Directory.CreateDirectory(DownloadsDir); string tempPath = Path.Join(DownloadsDir, "multiple_aes_async_writes.zip"); - var entries = new (string Name, byte[] Content, string Password, EncryptionMethod Encryption)[] + var entries = new (string Name, byte[] Content, string Password, ZipEncryptionMethod Encryption)[] { - ("async128.bin", Encoding.UTF8.GetBytes("AES-128 async content"), "pass128", EncryptionMethod.Aes128), - ("async192.bin", Encoding.UTF8.GetBytes("AES-192 async content"), "pass192", EncryptionMethod.Aes192), - ("async256.bin", Encoding.UTF8.GetBytes("AES-256 async content"), "pass256", EncryptionMethod.Aes256) + ("async128.bin", Encoding.UTF8.GetBytes("AES-128 async content"), "pass128", ZipEncryptionMethod.Aes128), + ("async192.bin", Encoding.UTF8.GetBytes("AES-192 async content"), "pass192", ZipEncryptionMethod.Aes192), + ("async256.bin", Encoding.UTF8.GetBytes("AES-256 async content"), "pass256", ZipEncryptionMethod.Aes256) }; // Act 1: Create entries with async writes @@ -1649,10 +1649,8 @@ public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() { foreach (var (name, content, pwd, encryption) in entries) { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(pwd, encryption); - - // Write asynchronously + var entry = archive.CreateEntry(name, pwd, encryption); + using var entryStream = entry.Open(); await entryStream.WriteAsync(content, 0, content.Length); await entryStream.FlushAsync(); } @@ -1703,8 +1701,8 @@ public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); - using var entryStream = entry.Open(password, EncryptionMethod.Aes128); + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal, password, ZipEncryptionMethod.Aes128); + using var entryStream = entry.Open(); // Write in chunks asynchronously const int chunkSize = 8192; @@ -1759,8 +1757,8 @@ public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes256); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); + using var entryStream = entry.Open(); // Write each part asynchronously foreach (var part in parts) @@ -1808,8 +1806,8 @@ public async Task AsyncReadInChunks_AES128_VerifyContent() using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, EncryptionMethod.Aes128); + var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); + using var entryStream = entry.Open(); await entryStream.WriteAsync(pattern, 0, pattern.Length); } @@ -1857,16 +1855,16 @@ public async Task MixedSyncAsyncOperations_AES192_RoundTrip() using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { // Synchronous write - var syncEntry = archive.CreateEntry(entries[0].Item1); - using (var syncStream = syncEntry.Open(entries[0].Item3, EncryptionMethod.Aes192)) + var syncEntry = archive.CreateEntry(entries[0].Item1, entries[0].Item3, ZipEncryptionMethod.Aes192); + using (var syncStream = syncEntry.Open()) { byte[] syncBytes = Encoding.UTF8.GetBytes(entries[0].Item2); syncStream.Write(syncBytes, 0, syncBytes.Length); } // Asynchronous write - var asyncEntry = archive.CreateEntry(entries[1].Item1); - using (var asyncStream = asyncEntry.Open(entries[1].Item3, EncryptionMethod.Aes192)) + var asyncEntry = archive.CreateEntry(entries[1].Item1, entries[1].Item3, ZipEncryptionMethod.Aes192); + using (var asyncStream = asyncEntry.Open()) { byte[] asyncBytes = Encoding.UTF8.GetBytes(entries[1].Item2); await asyncStream.WriteAsync(asyncBytes, 0, asyncBytes.Length); @@ -1912,7 +1910,7 @@ public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() if (File.Exists(archiveAfterUpdatePath)) File.Delete(archiveAfterUpdatePath); string password = "password123"; - var encryptionMethod = EncryptionMethod.Aes256; + var encryptionMethod = ZipEncryptionMethod.Aes256; // Step 1: Create initial archive with 3 encrypted entries using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) @@ -1926,8 +1924,8 @@ public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() foreach (var (name, content) in entries) { - var entry = archive.CreateEntry(name); - using var stream = entry.Open(password, encryptionMethod); + var entry = archive.CreateEntry(name, password, encryptionMethod); + using var stream = entry.Open(); using var writer = new StreamWriter(stream, Encoding.UTF8); await writer.WriteAsync(content); } @@ -2034,10 +2032,10 @@ public async Task Local_UpdateMode_EditAllEntries_MixedEncryption_ForWinRARInspe var entries = new[] { - ("entry_zipcrypto.txt", "Content ZipCrypto", EncryptionMethod.ZipCrypto), - ("entry_aes128.txt", "Content AES-128", EncryptionMethod.Aes128), - ("entry_aes192.txt", "Content AES-192", EncryptionMethod.Aes192), - ("entry_aes256.txt", "Content AES-256", EncryptionMethod.Aes256) + ("entry_zipcrypto.txt", "Content ZipCrypto", ZipEncryptionMethod.ZipCrypto), + ("entry_aes128.txt", "Content AES-128", ZipEncryptionMethod.Aes128), + ("entry_aes192.txt", "Content AES-192", ZipEncryptionMethod.Aes192), + ("entry_aes256.txt", "Content AES-256", ZipEncryptionMethod.Aes256) }; // Step 1: Create archive with entries using different encryption methods @@ -2045,8 +2043,8 @@ public async Task Local_UpdateMode_EditAllEntries_MixedEncryption_ForWinRARInspe { foreach (var (name, content, encryption) in entries) { - var entry = archive.CreateEntry(name); - using var stream = entry.Open(password, encryption); + var entry = archive.CreateEntry(name, password, encryption); + using var stream = entry.Open(); using var writer = new StreamWriter(stream, Encoding.UTF8); await writer.WriteAsync(content); } 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 d3627559e3aa97..802c10e99938b8 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -54,14 +54,6 @@ public override void Write(System.ReadOnlySpan buffer) { } public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override void WriteByte(byte value) { } } - public enum EncryptionMethod - { - None = 0, - ZipCrypto = 1, - Aes128 = 2, - Aes192 = 3, - Aes256 = 4, - } public partial class GZipStream : System.IO.Stream { public GZipStream(System.IO.Stream stream, System.IO.Compression.CompressionLevel compressionLevel) { } @@ -111,6 +103,8 @@ public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode public static System.Threading.Tasks.Task CreateAsync(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.ReadOnlySpan password, System.IO.Compression.ZipEncryptionMethod encryptionMethod) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.ReadOnlySpan password, System.IO.Compression.ZipEncryptionMethod encryptionMethod) { throw null; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } @@ -137,13 +131,9 @@ public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password) { throw null; } - public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } public System.IO.Stream Open(System.ReadOnlySpan password) { throw null; } - public System.IO.Stream Open(System.ReadOnlySpan password, System.IO.Compression.EncryptionMethod encryptionMethod) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.ReadOnlyMemory password, System.IO.Compression.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } @@ -160,6 +150,14 @@ public enum ZipCompressionMethod Deflate = 8, Deflate64 = 9, } + public enum ZipEncryptionMethod + { + None = 0, + ZipCrypto = 1, + Aes128 = 2, + Aes192 = 3, + Aes256 = 4, + } public sealed partial class ZLibCompressionOptions { public ZLibCompressionOptions() { } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index e197539b03c44c..af74002ef78687 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -53,7 +53,7 @@ - + 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..c52e71b8fa3719 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 @@ -285,6 +285,51 @@ public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressio return DoCreateEntry(entryName, compressionLevel); } + /// + /// Creates an empty encrypted entry in the Zip archive with the specified entry name. + /// The encryption key material is derived from the password and stored on the entry + /// so that a subsequent call to produces an encrypted stream. + /// + /// A path relative to the root of the archive, indicating the name of the entry to be created. + /// The password to use for encrypting the entry. + /// The encryption method to use. + /// A wrapper for the newly created file entry in the archive. + /// is a zero-length string. + /// is . + /// is empty. + /// The ZipArchive does not support writing. + /// The ZipArchive has already been closed. + public ZipArchiveEntry CreateEntry(string entryName, ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) + { + ZipArchiveEntry entry = DoCreateEntry(entryName, null); + entry.PrepareEncryption(password, encryptionMethod); + + return entry; + } + + /// + /// Creates an empty encrypted entry in the Zip archive with the specified entry name and compression level. + /// The encryption key material is derived from the password and stored on the entry + /// so that a subsequent call to produces an encrypted stream. + /// + /// A path relative to the root of the archive, indicating the name of the entry to be created. + /// The level of the compression (speed/memory vs. compressed size trade-off). + /// The password to use for encrypting the entry. + /// The encryption method to use. + /// A wrapper for the newly created file entry in the archive. + /// is a zero-length string. + /// is . + /// is empty. + /// The ZipArchive does not support writing. + /// The ZipArchive has already been closed. + public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressionLevel, ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) + { + ZipArchiveEntry entry = DoCreateEntry(entryName, compressionLevel); + entry.PrepareEncryption(password, encryptionMethod); + + return entry; + } + /// /// Releases the unmanaged resources used by ZipArchive and optionally finishes writing the archive and releases the managed resources. /// diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 7452c393032b5a..2859934488c450 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -154,61 +154,6 @@ public async Task OpenAsync(FileAccess access, ReadOnlyMemory pass } } - /// - /// Asynchronously opens the entry with the specified access mode, password, and encryption method for creating encrypted entries. - /// - /// The file access mode for the returned stream. - /// The password used to encrypt the entry. - /// The encryption method to use when creating the entry. - /// The token to monitor for cancellation requests. - /// A that represents the asynchronous open operation. - /// - /// The allowed values depend on the : - /// - /// : Not supported - encryption method is not needed for reading. - /// : and are allowed. - /// : Not supported - specifying encryption method in update mode is not allowed. - /// - /// - /// is not a valid value. - /// The requested access is not compatible with the archive's open mode. - /// The archive is in update mode. - /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. - /// The ZipArchive that this entry belongs to has been disposed. - public Task OpenAsync(FileAccess access, ReadOnlyMemory password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfInvalidArchive(); - - if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) - { - throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); - } - - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - if (access != FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - throw new InvalidOperationException(SR.EncryptionReadMode); - - case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return Task.FromResult(OpenInWriteMode(password.Span, encryptionMethod)); - - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - throw new InvalidDataException(SR.EncryptionUpdateMode); - } - } - public async Task OpenAsync(ReadOnlyMemory password, CancellationToken cancellationToken = default) { @@ -240,32 +185,6 @@ public async Task OpenAsync(ReadOnlyMemory password, CancellationT } } - public async Task OpenAsync(ReadOnlyMemory password, EncryptionMethod encryptionMethod, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - throw new InvalidOperationException(SR.EncryptionReadMode); - case ZipArchiveMode.Create: - return OpenInWriteMode(password.Span, encryptionMethod); - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false); - } - } - internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -662,7 +581,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (Encryption == EncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial != null) + if (Encryption == ZipEncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial != null) { // Write local file header first (with encryption flag set) // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header @@ -801,13 +720,13 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = Encryption; + ZipEncryptionMethod savedEncryption = Encryption; // For AES entries: clear Encryption so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). - if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) { - Encryption = EncryptionMethod.None; + Encryption = ZipEncryptionMethod.None; } await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); 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 0a964bd771ecd1..1f73754a48fc5c 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 @@ -46,7 +46,7 @@ public partial class ZipArchiveEntry private List? _lhUnknownExtraFields; private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; - private EncryptionMethod _encryptionMethod; + private ZipEncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; @@ -88,9 +88,9 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _aeVersion = aesField.VendorVersion; Encryption = aesField.AesStrength switch { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, + 1 => ZipEncryptionMethod.Aes128, + 2 => ZipEncryptionMethod.Aes192, + 3 => ZipEncryptionMethod.Aes256, _ => throw new InvalidDataException(SR.InvalidAesStrength) }; } @@ -448,32 +448,6 @@ public Stream Open(ReadOnlySpan password) } } - /// - /// Opens the entry for Creation with the specified encryption method. - /// - /// A Stream that represents the contents of the entry. - /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. - /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. - /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(ReadOnlySpan password, EncryptionMethod encryptionMethod) - { - ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - throw new InvalidOperationException(SR.EncryptionReadMode); - case ZipArchiveMode.Create: - return OpenInWriteMode(password, encryptionMethod); - case ZipArchiveMode.Update: - default: - throw new InvalidDataException(SR.EncryptionUpdateMode); - } - } - /// /// Opens the entry with the specified access mode. This allows for more granular control over the returned stream's capabilities. /// @@ -568,36 +542,6 @@ public Stream Open(FileAccess access, ReadOnlySpan password) } } - public Stream Open(FileAccess access, ReadOnlySpan password, EncryptionMethod encryptionMethod) - { - ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - - if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) - throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - if (access != FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - throw new InvalidOperationException(SR.EncryptionReadMode); - - case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return OpenInWriteMode(password, encryptionMethod); - - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - throw new InvalidDataException(SR.EncryptionUpdateMode); - } - } - /// /// Returns the FullName of the entry. /// @@ -1035,17 +979,17 @@ private bool IsZipCryptoEncrypted() private bool UseAesEncryption() { - return Encryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; + return Encryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256; } private bool IsAesEncrypted => (ushort)_headerCompressionMethod == WinZipAesMethod; - private static int GetAesKeySizeBits(EncryptionMethod encryption) + private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) { return encryption switch { - EncryptionMethod.Aes128 => 128, - EncryptionMethod.Aes192 => 192, - EncryptionMethod.Aes256 => 256, + ZipEncryptionMethod.Aes128 => 128, + ZipEncryptionMethod.Aes192 => 192, + ZipEncryptionMethod.Aes256 => 256, _ => 256 // Default to AES-256 }; } @@ -1150,7 +1094,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read return decompressedStream; } - private WrappedStream OpenInWriteMode(ReadOnlySpan password = default, EncryptionMethod encryptionMethod = EncryptionMethod.None) + private WrappedStream OpenInWriteMode() { if (_everOpenedForWrite) throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); @@ -1158,28 +1102,26 @@ private WrappedStream OpenInWriteMode(ReadOnlySpan password = default, Enc // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded _archive.DebugAssertIsStillArchiveStreamOwner(this); - return OpenInWriteModeCore(password, encryptionMethod); + return OpenInWriteModeCore(); } - private WrappedStream OpenInWriteModeCore(ReadOnlySpan password = default, EncryptionMethod encryptionMethod = EncryptionMethod.None) + private WrappedStream OpenInWriteModeCore() { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; + // Use the encryption method pre-configured via PrepareEncryption (CreateEntry with password). + ZipEncryptionMethod encryptionMethod = Encryption; + // Build the stream stack with encryption if needed Stream targetStream = _archive.ArchiveStream; Stream? encryptionStream = null; - if (encryptionMethod == EncryptionMethod.ZipCrypto) + if (encryptionMethod == ZipEncryptionMethod.ZipCrypto) { - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - - Encryption = encryptionMethod; + ZipCryptoKeys keyMaterial = _derivedZipCryptoKeyMaterial + ?? throw new InvalidOperationException(SR.EmptyPassword); - ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); targetStream = ZipCryptoStream.Create( @@ -1191,22 +1133,15 @@ private WrappedStream OpenInWriteModeCore(ReadOnlySpan password = default, leaveOpen: true); encryptionStream = targetStream; } - else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) + else if (encryptionMethod is ZipEncryptionMethod.Aes256 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes128) { if (OperatingSystem.IsBrowser()) { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } - if (password.IsEmpty) - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - - Encryption = encryptionMethod; - - int keySizeBits = GetAesKeySizeBits(encryptionMethod); - - // Derive key material from password with new random salt - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); + WinZipAesKeyMaterial keyMaterial = _derivedAesKeyMaterial + ?? throw new InvalidOperationException(SR.EmptyPassword); targetStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, @@ -1217,7 +1152,7 @@ private WrappedStream OpenInWriteModeCore(ReadOnlySpan password = default, encryptionStream = targetStream; } - bool isAesEncryption = encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128; + bool isAesEncryption = encryptionMethod is ZipEncryptionMethod.Aes256 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes128; CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, @@ -1229,7 +1164,7 @@ private WrappedStream OpenInWriteModeCore(ReadOnlySpan password = default, entry._archive.ReleaseArchiveStream(entry); entry._outstandingWriteStream = null; }, - streamForPosition: encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); + streamForPosition: encryptionMethod != ZipEncryptionMethod.None ? _archive.ArchiveStream : null); _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod, encryptionStream); @@ -1302,7 +1237,7 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) if (IsZipCryptoEncrypted()) { _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); - Encryption = EncryptionMethod.ZipCrypto; + Encryption = ZipEncryptionMethod.ZipCrypto; } else if (UseAesEncryption()) { @@ -1321,6 +1256,33 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) _crc32 = 0; } + /// + /// Pre-derives encryption key material from a password for use when the entry is later opened for writing. + /// Called by . + /// + internal void PrepareEncryption(ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) + { + if (password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + + Encryption = encryptionMethod; + + if (encryptionMethod == ZipEncryptionMethod.ZipCrypto) + { + _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); + } + else if (encryptionMethod is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + int keySizeBits = GetAesKeySizeBits(encryptionMethod); + _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); + } + } + /// /// Creates a WinZip AES extra field for writing to local/central directory headers. /// @@ -1330,9 +1292,9 @@ private WinZipAesExtraField CreateAesExtraField() { AesStrength = Encryption switch { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, + ZipEncryptionMethod.Aes128 => (byte)1, + ZipEncryptionMethod.Aes192 => (byte)2, + ZipEncryptionMethod.Aes256 => (byte)3, _ => (byte)3 // Default to AES-256 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression @@ -1487,7 +1449,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; - internal EncryptionMethod Encryption { get => _encryptionMethod; private set => _encryptionMethod = value; } + internal ZipEncryptionMethod Encryption { get => _encryptionMethod; private set => _encryptionMethod = value; } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { @@ -1516,7 +1478,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { - if (Encryption == EncryptionMethod.ZipCrypto) + if (Encryption == ZipEncryptionMethod.ZipCrypto) { // Streaming mode for encryption: sizes and CRC unknown upfront _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; @@ -1693,7 +1655,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (Encryption == EncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial is not null) + if (Encryption == ZipEncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial is not null) { WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); @@ -1797,16 +1759,16 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = Encryption; + ZipEncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; // For AES entries: set CompressionMethod to Aes so header writes method 99, // but clear _encryptionMethod so WriteLocalFileHeader doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). - if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) { CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; - Encryption = EncryptionMethod.None; + Encryption = ZipEncryptionMethod.None; } WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); @@ -2087,12 +2049,12 @@ private sealed class DirectToArchiveWriterStream : Stream private readonly ZipArchiveEntry _entry; private bool _usedZip64inLH; private bool _canWrite; - private readonly EncryptionMethod _encryption; + private readonly ZipEncryptionMethod _encryption; private readonly Stream? _encryptionStream; // makes the assumption that somewhere down the line, crcSizeStream is eventually writing directly to the archive // this class calls other functions on ZipArchiveEntry that write directly to the archive - public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, EncryptionMethod encryptionMethod = EncryptionMethod.None, Stream? encryptionStream = null) + public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, ZipEncryptionMethod encryptionMethod = ZipEncryptionMethod.None, Stream? encryptionStream = null) { _position = 0; _crcSizeStream = crcSizeStream; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs similarity index 96% rename from src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs rename to src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs index b76684ab6cc156..801370e7d7a4e9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/EncryptionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs @@ -6,7 +6,7 @@ namespace System.IO.Compression /// /// Specifies the encryption method used to encrypt an entry in a zip archive. /// - public enum EncryptionMethod + public enum ZipEncryptionMethod { /// /// No Encryption is applied to the entry. diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 74335079aff07a..bb9fd684a4d08c 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -52,14 +52,28 @@ static WinZipAesStreamConformanceTests() s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; s_winZipAesKeyMaterialType = assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; - // Use WinZipAesKeyMaterial.Create directly since it's the underlying implementation - // and we need a delegate to handle ReadOnlySpan (ref struct can't be boxed for MethodInfo.Invoke) MethodInfo createKeyMethod = s_winZipAesKeyMaterialType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(ReadOnlySpan), typeof(byte[]), typeof(int) }, null)!; - s_createKey = createKeyMethod.CreateDelegate(); + + // CreateDelegate can't handle value-type return covariance (struct → object boxing). + // Use DynamicMethod to emit a wrapper that calls the target and boxes the result. + var dm = new System.Reflection.Emit.DynamicMethod( + "CreateKeyWrapper", + typeof(object), + new[] { typeof(ReadOnlySpan), typeof(byte[]), typeof(int) }, + typeof(WinZipAesStreamConformanceTests).Module, + skipVisibility: true); + var il = dm.GetILGenerator(); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_1); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_2); + il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); + il.Emit(System.Reflection.Emit.OpCodes.Box, s_winZipAesKeyMaterialType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); s_createMethod = s_winZipAesStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index ccab3edbcc1cc5..dc91630a4dfc59 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -20,7 +20,8 @@ public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests private static readonly Type s_zipCryptoStreamType; private static readonly Type s_zipCryptoKeysType; - private static readonly MethodInfo s_createKeyMethod; + private delegate object CreateKeyDelegate(ReadOnlySpan password); + private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createEncryptionMethod; private static readonly MethodInfo s_createDecryptionMethod; @@ -30,12 +31,26 @@ static ZipCryptoStreamConformanceTests() s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; s_zipCryptoKeysType = assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; - s_createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", + MethodInfo createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(ReadOnlyMemory) }, + new[] { typeof(ReadOnlySpan) }, null)!; + // Use DynamicMethod to box the struct return value. + var dm = new System.Reflection.Emit.DynamicMethod( + "CreateKeyWrapper", + typeof(object), + new[] { typeof(ReadOnlySpan) }, + typeof(ZipCryptoStreamConformanceTests).Module, + skipVisibility: true); + var il = dm.GetILGenerator(); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); + il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); + il.Emit(System.Reflection.Emit.OpCodes.Box, s_zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); + s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, @@ -58,7 +73,7 @@ static ZipCryptoStreamConformanceTests() protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) { var ms = new MemoryStream(); - object keys = s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + object keys = s_createKey(TestPassword); var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { @@ -76,7 +91,7 @@ static ZipCryptoStreamConformanceTests() protected override Task CreateReadOnlyStreamCore(byte[]? initialData) { byte[] plaintext = initialData ?? Array.Empty(); - object keys = s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + object keys = s_createKey(TestPassword); // The check byte is the HIGH byte of the password verifier (little-endian format) byte expectedCheckByte = (byte)(PasswordVerifier >> 8); diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 5e51ae5135bbae..455922ac7172a0 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -21,7 +21,9 @@ public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamCon private const ushort PasswordVerifier = 0x1234; private static readonly Type s_zipCryptoStreamType; - private static readonly MethodInfo s_createKeyMethod; + private static readonly Type s_zipCryptoKeysType; + private delegate object CreateKeyDelegate(ReadOnlySpan password); + private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createEncryptionMethod; private static readonly MethodInfo s_createDecryptionMethod; private static readonly MethodInfo s_createAsyncMethod; @@ -30,29 +32,44 @@ static ZipCryptoStreamWrappedConformanceTests() { var assembly = typeof(ZipArchive).Assembly; s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; + s_zipCryptoKeysType = assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; - s_createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", - BindingFlags.Public | BindingFlags.Static, + MethodInfo createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(ReadOnlyMemory) }, + new[] { typeof(ReadOnlySpan) }, null)!; + // Use DynamicMethod to box the struct return value. + var dm = new System.Reflection.Emit.DynamicMethod( + "CreateKeyWrapper", + typeof(object), + new[] { typeof(ReadOnlySpan) }, + typeof(ZipCryptoStreamWrappedConformanceTests).Module, + skipVisibility: true); + var il = dm.GetILGenerator(); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); + il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); + il.Emit(System.Reflection.Emit.OpCodes.Box, s_zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); + s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + new[] { typeof(Stream), s_zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, null)!; s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, null)!; s_createAsyncMethod = s_zipCryptoStreamType.GetMethod("CreateAsync", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), typeof(byte[]), typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, + new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, null); } @@ -76,7 +93,7 @@ protected override Task CreateConnectedStreamsAsync() protected override async Task CreateWrappedConnectedStreamsAsync(StreamPair wrapped, bool leaveOpen = false) { - byte[] keyBytes = (byte[])s_createKeyMethod.Invoke(null, new object[] { TestPassword.AsMemory() })!; + object keys = s_createKey(TestPassword); // The check byte is the HIGH byte of the password verifier (little-endian format) byte expectedCheckByte = (byte)(PasswordVerifier >> 8); // 0x12 @@ -84,7 +101,7 @@ protected override async Task CreateWrappedConnectedStreamsAsync(Str // Create the encryption stream (write-only) - wraps stream1 var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] { - wrapped.Stream1, keyBytes, PasswordVerifier, true /* encrypting */, null /* crc32 */, leaveOpen + wrapped.Stream1, keys, PasswordVerifier, true /* encrypting */, null /* crc32 */, leaveOpen })!; // Write and flush the header so the decryption stream can read it @@ -96,7 +113,7 @@ protected override async Task CreateWrappedConnectedStreamsAsync(Str // Now create the decryption stream (read-only) - wraps stream2 // This will read and validate the 12-byte header // Use async factory method to support AsyncOnlyStream wrappers - var decryptStream = await CreateDecryptStreamAsync(wrapped.Stream2, keyBytes, expectedCheckByte, leaveOpen).ConfigureAwait(false); + var decryptStream = await CreateDecryptStreamAsync(wrapped.Stream2, keys, expectedCheckByte, leaveOpen).ConfigureAwait(false); // Read the byte we wrote to trigger the header byte[] readBuffer = new byte[1]; @@ -105,12 +122,12 @@ protected override async Task CreateWrappedConnectedStreamsAsync(Str return (encryptStream, decryptStream); } - private static async Task CreateDecryptStreamAsync(Stream baseStream, byte[] keyBytes, byte expectedCheckByte, bool leaveOpen) + private static async Task CreateDecryptStreamAsync(Stream baseStream, object keys, byte expectedCheckByte, bool leaveOpen) { // CreateAsync returns Task, await it and get the result var task = (Task)s_createAsyncMethod.Invoke(null, new object[] { - baseStream, keyBytes, expectedCheckByte, false /* encrypting */, CancellationToken.None, leaveOpen + baseStream, keys, expectedCheckByte, false /* encrypting */, CancellationToken.None, leaveOpen })!; await task.ConfigureAwait(false); From 5a5012f394a2b6cf88b89b27b12ba184132a2412 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 1 Apr 2026 15:49:36 +0200 Subject: [PATCH 56/83] add ZipEncryptionMethod public getter --- .../tests/ZipFile.Encryption.cs | 34 +++++++++++++++++++ .../ref/System.IO.Compression.cs | 1 + .../System/IO/Compression/ZipArchiveEntry.cs | 17 ++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 238833900c9af2..9f698a1a528373 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1560,5 +1560,39 @@ public async Task Browser_AesEncryption_Throws_PlatformNotSupportedException(Zip #endregion + [Theory] + [InlineData(ZipEncryptionMethod.None)] + [InlineData(ZipEncryptionMethod.ZipCrypto)] + [InlineData(ZipEncryptionMethod.Aes128)] + [InlineData(ZipEncryptionMethod.Aes192)] + [InlineData(ZipEncryptionMethod.Aes256)] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public void EncryptionMethod_Property_ReflectsEntryEncryption(ZipEncryptionMethod expectedMethod) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "Hello"; + string password = "password123"; + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = expectedMethod != ZipEncryptionMethod.None + ? archive.CreateEntry(entryName, password, expectedMethod) + : archive.CreateEntry(entryName); + + using (StreamWriter writer = new StreamWriter(entry.Open())) + { + writer.Write(content); + } + } + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName)!; + Assert.Equal(expectedMethod, entry.EncryptionMethod); + Assert.Equal(expectedMethod != ZipEncryptionMethod.None, entry.IsEncrypted); + } + } + } } 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 802c10e99938b8..56ed147b740a04 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -124,6 +124,7 @@ internal ZipArchiveEntry() { } public int ExternalAttributes { get { throw null; } set { } } public string FullName { get { throw null; } } public bool IsEncrypted { get { throw null; } } + public System.IO.Compression.ZipEncryptionMethod EncryptionMethod { get { throw null; } } public System.DateTimeOffset LastWriteTime { get { throw null; } set { } } public long Length { get { throw null; } } public string Name { get { throw null; } } 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 1f73754a48fc5c..d79eb6218952a2 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 @@ -94,6 +94,12 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _ => throw new InvalidDataException(SR.InvalidAesStrength) }; } + else if (_isEncrypted) + { + // Encrypted but no AES extra field means ZipCrypto + Encryption = ZipEncryptionMethod.ZipCrypto; + CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + } else { // Non-AES entry: compression method from CD is the real method @@ -205,6 +211,17 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) /// public bool IsEncrypted => _isEncrypted; + /// + /// Gets the encryption method used to encrypt the entry. + /// + /// + /// if the entry is not encrypted; + /// otherwise, the specific encryption method (e.g., , + /// , , + /// or ). + /// + public ZipEncryptionMethod EncryptionMethod => _encryptionMethod; + /// /// Gets the compression method used to compress the entry. /// From c1e0e642588abf25fc9e4641a3fddfe60cbb3993 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 1 Apr 2026 15:49:50 +0200 Subject: [PATCH 57/83] update stream fuzzers --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 35 +++++++++++++++---- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 27 +++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 55c9d834b8da82..92ee68b2c2b216 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -26,16 +26,37 @@ internal sealed class WinZipAesStreamFuzzer : IFuzzer #pragma warning restore IL2026 // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, - // so we use a strongly-typed delegate instead. + // and CreateDelegate cannot handle struct-to-object return covariance. + // Use DynamicMethod to emit a wrapper that boxes the struct return value. private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); #pragma warning disable IL2077 // dynamic access to non-public members - private static readonly CreateKeyDelegate _createKey = _winZipAesKeyMaterialType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], - modifiers: null)!.CreateDelegate(); + private static readonly CreateKeyDelegate _createKey = CreateBoxingDelegate(); + + private static CreateKeyDelegate CreateBoxingDelegate() + { + MethodInfo createKeyMethod = _winZipAesKeyMaterialType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], + modifiers: null)!; + + var dm = new System.Reflection.Emit.DynamicMethod( + "CreateKeyWrapper", + typeof(object), + [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], + typeof(WinZipAesStreamFuzzer).Module, + skipVisibility: true); + var il = dm.GetILGenerator(); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_1); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_2); + il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); + il.Emit(System.Reflection.Emit.OpCodes.Box, _winZipAesKeyMaterialType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + return dm.CreateDelegate(); + } private static readonly MethodInfo _createMethod = _winZipAesStreamType.GetMethod( "Create", diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 5912588737ca7f..d427b0972f0cf7 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -34,13 +34,32 @@ public void FuzzTarget(ReadOnlySpan bytes) #pragma warning restore IL2026 // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, - // so we use a strongly-typed delegate instead. + // and CreateDelegate cannot handle struct-to-object return covariance. + // Use DynamicMethod to emit a wrapper that boxes the struct return value. private delegate object CreateKeyDelegate(ReadOnlySpan password); #pragma warning disable IL2077 // dynamic access to non-public members - private static readonly CreateKeyDelegate _createKey = _zipCryptoStreamType.GetMethod( - "CreateKey", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!.CreateDelegate(); + private static readonly CreateKeyDelegate _createKey = CreateBoxingDelegate(); + + private static CreateKeyDelegate CreateBoxingDelegate() + { + MethodInfo createKeyMethod = _zipCryptoStreamType.GetMethod( + "CreateKey", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + + var dm = new System.Reflection.Emit.DynamicMethod( + "CreateKeyWrapper", + typeof(object), + [typeof(ReadOnlySpan)], + typeof(ZipCryptoStreamFuzzer).Module, + skipVisibility: true); + var il = dm.GetILGenerator(); + il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); + il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); + il.Emit(System.Reflection.Emit.OpCodes.Box, _zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + return dm.CreateDelegate(); + } private static readonly MethodInfo _createMethod = _zipCryptoStreamType.GetMethod( "Create", From b3000c2d374d9a4e3eaff69e36b4b3f968899504 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 6 Apr 2026 11:27:00 +0200 Subject: [PATCH 58/83] address PR review comments - Open(password) on unencrypted entries no longer throws, password is ignored - Open(password) with empty password on unencrypted entries no longer throws - Same for Open(access, password) and all OpenAsync overloads - ExtractToDirectory/ExtractToFile simplified to always pass password through - Skip CRC validation for AE-2 entries (CRC is 0, HMAC handles integrity) - AE-1 entries still validate CRC - Fix async OpenInReadModeAsync to wrap in CrcValidatingReadStream - Fix XML doc double /// prefix and malformed paramref tags - Fix WinZipAesExtraField constructor parameter casing (PascalCase -> camelCase) - Fix missing license header in ZipCryptoStreamConformanceTests.cs - Use Debug.Assert for internal salt size validation - Validate encryption != None requires non-empty password in Create paths - Add XML docs to CreateEntryFromFile password overload - Update tests to match new Open(password) behavior on unencrypted entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IO/Compression/ZipFile.Create.Async.cs | 4 ++ .../System/IO/Compression/ZipFile.Create.cs | 4 ++ .../IO/Compression/ZipFile.Extract.Async.cs | 16 ++--- .../System/IO/Compression/ZipFile.Extract.cs | 16 ++--- .../ZipFileExtensions.ZipArchive.Create.cs | 17 +++++ ...FileExtensions.ZipArchive.Extract.Async.cs | 7 +- .../ZipFileExtensions.ZipArchive.Extract.cs | 7 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 10 +-- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 10 +-- .../tests/ZipFile.Encryption.cs | 8 ++- .../tests/ZipFile.Extract.cs | 10 +-- .../IO/Compression/WinZipAesKeyMaterial.cs | 6 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 61 +++++++++-------- .../System/IO/Compression/ZipArchiveEntry.cs | 66 ++++++++++--------- .../src/System/IO/Compression/ZipBlocks.cs | 20 +++--- .../tests/ZipCryptoStreamConformanceTests.cs | 1 + 16 files changed, 137 insertions(+), 126 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs index bdc6f376600968..2999d2bea82b09 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs @@ -442,6 +442,8 @@ public static Task CreateFromDirectoryAsync(string sourceDirectoryName, Stream d public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, ZipFileCreationOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(options); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(options)); cancellationToken.ThrowIfCancellationRequested(); (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); @@ -465,6 +467,8 @@ public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, st public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, Stream destination, ZipFileCreationOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(options); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(options)); cancellationToken.ThrowIfCancellationRequested(); sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs index 3e2c8fd65f4f3e..4daf0e9090bd75 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs @@ -411,6 +411,8 @@ public static void CreateFromDirectory(string sourceDirectoryName, Stream destin public static void CreateFromDirectory(string sourceDirectoryName, string destinationArchiveFileName, ZipFileCreationOptions options) { ArgumentNullException.ThrowIfNull(options); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(options)); (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); @@ -429,6 +431,8 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin public static void CreateFromDirectory(string sourceDirectoryName, Stream destination, ZipFileCreationOptions options) { ArgumentNullException.ThrowIfNull(options); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(options)); sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index 9007dfd3516213..fc86f429cc13bb 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -111,7 +111,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The path to the archive on the file system that is to be extracted. /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -169,7 +169,7 @@ public static Task ExtractToDirectoryAsync(string sourceArchiveFileName, string /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -237,7 +237,7 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -291,7 +291,7 @@ private static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// or is . /// The specified path in exceeds the system-defined maximum length. /// The specified path is invalid (for example, it is on an unmapped drive). @@ -323,7 +323,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// or is . /// The specified path in exceeds the system-defined maximum length. /// The specified path is invalid (for example, it is on an unmapped drive). @@ -361,7 +361,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . @@ -402,7 +402,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . @@ -458,7 +458,7 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs index c0c384a077c413..8328d119cc80ef 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs @@ -102,7 +102,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the archive on the file system that is to be extracted. /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -157,7 +157,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -219,7 +219,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. /// True to indicate overwrite. /// The encoding to use when reading or writing entry names and comments in this ZipArchive. - /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// NOTE: Specifying this parameter to values other than null is discouraged. /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support /// UTF-8 encoding for entry names or comments.
/// This value is used as follows:
@@ -261,7 +261,7 @@ private static void ExtractToDirectory(string sourceArchiveFileName, string dest /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// or is . /// The specified path in exceeds the system-defined maximum length. /// The specified path is invalid (for example, it is on an unmapped drive). @@ -290,7 +290,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// or is . /// The specified path in exceeds the system-defined maximum length. /// The specified path is invalid (for example, it is on an unmapped drive). @@ -325,7 +325,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . @@ -363,7 +363,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . @@ -411,7 +411,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory /// If is set to , entry names and comments are decoded according to the following rules: /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. - /// > is , contains only white space, or contains at least one invalid character. + /// is , contains only white space, or contains at least one invalid character. /// -or- /// is set to a Unicode encoding other than UTF-8. /// or is . diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 52f11694b91955..eabdff52adc6ac 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -77,6 +77,17 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); + /// + /// Adds a file from the file system to the archive under the specified entry name with encryption. + /// The new entry in the archive will contain the contents of the file. + /// The last write time of the archive entry is set to the last write time of the file on the file system. + /// + /// The zip archive to add the file to. + /// The path to the file on the file system to be copied from. + /// The name of the entry to be created. + /// The password used to encrypt the entry. + /// The encryption method to use. + /// A wrapper for the newly created entry. public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, ReadOnlySpan password, ZipEncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, null, password, encryption); @@ -163,6 +174,12 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); + if (encryption != ZipEncryptionMethod.None && password.IsEmpty) + { + fs.Dispose(); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } + ZipArchiveEntry entry; if (!password.IsEmpty && encryption != ZipEncryptionMethod.None) { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index ac4824dcdaf26e..fc0e81cc2351a8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -94,15 +94,10 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string cancellationToken.ThrowIfCancellationRequested(); - ReadOnlyMemory password = options.Password; - foreach (ZipArchiveEntry entry in source.Entries) { cancellationToken.ThrowIfCancellationRequested(); - if (!password.IsEmpty) - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, options.OverwriteFiles, password, cancellationToken).ConfigureAwait(false); - else - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, options.OverwriteFiles, cancellationToken).ConfigureAwait(false); + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, options.OverwriteFiles, options.Password, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index bc7156b056272e..2fd56fc2bfa60f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -87,14 +87,9 @@ public static void ExtractToDirectory(this ZipArchive source, string destination ArgumentNullException.ThrowIfNull(destinationDirectoryName); ArgumentNullException.ThrowIfNull(options); - ReadOnlySpan password = options.Password.Span; - foreach (ZipArchiveEntry entry in source.Entries) { - if (!password.IsEmpty) - entry.ExtractRelativeToDirectory(destinationDirectoryName, options.OverwriteFiles, password); - else - entry.ExtractRelativeToDirectory(destinationDirectoryName, options.OverwriteFiles); + entry.ExtractRelativeToDirectory(destinationDirectoryName, options.OverwriteFiles, options.Password.Span); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 08b98ff6c98d67..d2cf07ea6bb709 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -125,19 +125,11 @@ public static Task ExtractToFileAsync(this ZipArchiveEntry source, string destin { ArgumentNullException.ThrowIfNull(options); - if (!options.Password.IsEmpty) - return ExtractToFileAsync(source, destinationFileName, options.OverwriteFiles, options.Password, cancellationToken); - else - return ExtractToFileAsync(source, destinationFileName, options.OverwriteFiles, cancellationToken); + return ExtractToFileAsync(source, destinationFileName, options.OverwriteFiles, options.Password, cancellationToken); } private static async Task ExtractToFileAsync(ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) { - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - cancellationToken.ThrowIfCancellationRequested(); ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: true, out FileStreamOptions fileStreamOptions); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 4579ab90d042f4..7cfd6df9c39ff1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -116,19 +116,11 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination { ArgumentNullException.ThrowIfNull(options); - if (!options.Password.IsEmpty) - ExtractToFile(source, destinationFileName, options.OverwriteFiles, options.Password.Span); - else - ExtractToFile(source, destinationFileName, options.OverwriteFiles); + ExtractToFile(source, destinationFileName, options.OverwriteFiles, options.Password.Span); } private static void ExtractToFile(ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlySpan password) { - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } - ExtractToFileInitialize(source, destinationFileName, overwrite, useAsync: false, out FileStreamOptions fileStreamOptions); // When overwriting, extract to a temporary file first to avoid corrupting the destination file diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 9f698a1a528373..3dfbade14a34d1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -206,7 +206,7 @@ public void MissingPassword_Throws_InvalidDataException() [Theory] [MemberData(nameof(Get_Booleans_Data))] - public async Task OpeningPlainEntryWithPassword_Throws(bool async) + public async Task OpeningPlainEntryWithPassword_Succeeds(bool async) { string archivePath = GetTempArchivePath(); var entries = new[] { ("plain.txt", "content", (string?)null, (ZipEncryptionMethod?)null) }; @@ -216,7 +216,11 @@ public async Task OpeningPlainEntryWithPassword_Throws(bool async) { ZipArchiveEntry entry = archive.GetEntry("plain.txt"); Assert.NotNull(entry); - Assert.Throws(() => entry.Open("password")); + + // Password is ignored for unencrypted entries + using Stream s = entry.Open("password"); + using var reader = new StreamReader(s); + Assert.Equal("content", reader.ReadToEnd()); } } diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index fa4a51815f962a..9ac5027db13986 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -654,11 +654,13 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() string content = await r.ReadToEndAsync(); Assert.Equal(it.Content, content); - // Ensure opening a plain entry with a password is rejected (or simply ignored depending on API) - Assert.ThrowsAny(() => + // Opening a plain entry with a password should succeed — password is ignored + using (var s = e.Open("some-password")) + using (var r2 = new StreamReader(s, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { - using var _ = e.Open("some-password"); - }); + string content2 = await r2.ReadToEndAsync(); + Assert.Equal(it.Content, content2); + } } } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 9fd3db901503f8..8189f6ae56f5b8 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; @@ -83,10 +84,7 @@ internal static WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? } else { - if (salt.Length != saltSize) - { - throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); - } + Debug.Assert(salt.Length == saltSize, $"Salt must be {saltSize} bytes for AES-{keySizeBits}."); saltBytes = salt; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 2859934488c450..dc6d4b36102efa 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -116,40 +116,44 @@ public async Task OpenAsync(FileAccess access, ReadOnlyMemory pass { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + bool usePassword = IsEncrypted && !password.IsEmpty; + switch (_archive.Mode) { case ZipArchiveMode.Read: if (access != FileAccess.Read) throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - if (!IsEncrypted) - throw new InvalidDataException(SR.EntryNotEncrypted); - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) + : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); case ZipArchiveMode.Create: - throw new InvalidOperationException(SR.EntriesInCreateMode); + if (access == FileAccess.Read) + throw new InvalidOperationException(SR.CannotBeReadInCreateMode); + return OpenInWriteMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!IsEncrypted) - throw new InvalidDataException(SR.EntryNotEncrypted); switch (access) { case FileAccess.Read: - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) + : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); case FileAccess.Write: - return await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken, password).ConfigureAwait(false) + : await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken).ConfigureAwait(false); case FileAccess.ReadWrite: default: - return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false) + : await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); } } } @@ -159,29 +163,23 @@ public async Task OpenAsync(ReadOnlyMemory password, CancellationT { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } + + bool usePassword = IsEncrypted && !password.IsEmpty; switch (_archive.Mode) { case ZipArchiveMode.Read: - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) + : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); case ZipArchiveMode.Create: - throw new InvalidOperationException(SR.EncryptionNotSpecified); + return OpenInWriteMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false); + return usePassword + ? await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false) + : await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); } } @@ -385,7 +383,12 @@ private async Task OpenInReadModeGetDataCompressorAsync(long offsetOfCom // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); - return decompressedStream; + // AE-2 encrypted entries store CRC as 0 and use HMAC for integrity instead, + // so skip CRC validation for those. AE-1 entries store a valid CRC. + if (IsAesEncrypted && _aeVersion == 2) + return decompressedStream; + + return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); } private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStream, ReadOnlyMemory password, CancellationToken cancellationToken) 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 f3b760359440a8..5305eb125f13cb 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 @@ -430,38 +430,32 @@ public Stream Open() /// /// Opens the entry for reading or updating with the specified password. + /// If the entry is not encrypted, the password is ignored and the entry is opened normally. /// /// A Stream that represents the contents of the entry. /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. /// The requested access is not compatible with the archive's open mode. - /// The password provided is empty. + /// The password used to decrypt the entry. If the entry is not encrypted, this parameter is ignored. public Stream Open(ReadOnlySpan password) { ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } + switch (_archive.Mode) { case ZipArchiveMode.Read: - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return OpenInReadMode(checkOpenable: true, password); + return IsEncrypted && !password.IsEmpty + ? OpenInReadMode(checkOpenable: true, password) + : OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: - throw new InvalidOperationException(SR.EntriesInCreateMode); + return OpenInWriteMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!IsEncrypted) - { - throw new InvalidDataException(SR.EntryNotEncrypted); - } - return OpenInUpdateMode(loadExistingContent: true, password); + return IsEncrypted && !password.IsEmpty + ? OpenInUpdateMode(loadExistingContent: true, password) + : OpenInUpdateMode(); } } @@ -522,39 +516,44 @@ public Stream Open(FileAccess access) public Stream Open(FileAccess access, ReadOnlySpan password) { ThrowIfInvalidArchive(); - if (password.IsEmpty) - { - throw new ArgumentException(SR.EmptyPassword, nameof(password)); - } + if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + bool usePassword = IsEncrypted && !password.IsEmpty; + switch (_archive.Mode) { case ZipArchiveMode.Read: if (access != FileAccess.Read) throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - if (!IsEncrypted) - throw new InvalidDataException(SR.EntryNotEncrypted); - return OpenInReadMode(checkOpenable: true, password); + return usePassword + ? OpenInReadMode(checkOpenable: true, password) + : OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: - throw new InvalidOperationException(SR.EntriesInCreateMode); + if (access == FileAccess.Read) + throw new InvalidOperationException(SR.CannotBeReadInCreateMode); + return OpenInWriteMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!IsEncrypted) - throw new InvalidDataException(SR.EntryNotEncrypted); switch (access) { case FileAccess.Read: - return OpenInReadMode(checkOpenable: true, password); + return usePassword + ? OpenInReadMode(checkOpenable: true, password) + : OpenInReadMode(checkOpenable: true); case FileAccess.Write: - return OpenInUpdateMode(loadExistingContent: false, password); + return usePassword + ? OpenInUpdateMode(loadExistingContent: false, password) + : OpenInUpdateMode(loadExistingContent: false); case FileAccess.ReadWrite: default: - return OpenInUpdateMode(loadExistingContent: true, password); + return usePassword + ? OpenInUpdateMode(loadExistingContent: true, password) + : OpenInUpdateMode(loadExistingContent: true); } } } @@ -1084,14 +1083,14 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) return uncompressedStream; } - private CrcValidatingReadStream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = default) + private Stream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = default) { if (checkOpenable) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } - private CrcValidatingReadStream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlySpan password = default) + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlySpan password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); Stream streamToDecompress; @@ -1109,6 +1108,11 @@ private CrcValidatingReadStream OpenInReadModeGetDataCompressor(long offsetOfCom // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); + // AE-2 encrypted entries store CRC as 0 and use HMAC for integrity instead, + // so skip CRC validation for those. AE-1 entries store a valid CRC. + if (IsAesEncrypted && _aeVersion == 2) + return decompressedStream; + return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); } private WrappedStream OpenInWriteMode() 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 9765d2830770e6..b0b08a1887bd95 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 @@ -715,11 +715,11 @@ internal struct WinZipAesExtraField private byte _aesStrength; private ushort _compressionMethod; - public WinZipAesExtraField(ushort VendorVersion, byte AesStrength, ushort CompressionMethod) + public WinZipAesExtraField(ushort vendorVersion, byte aesStrength, ushort compressionMethod) { - this.VendorVersion = VendorVersion; - this.AesStrength = AesStrength; - this.CompressionMethod = CompressionMethod; + VendorVersion = vendorVersion; + AesStrength = aesStrength; + CompressionMethod = compressionMethod; } public ushort VendorVersion { get => _vendorVersion; set => _vendorVersion = value; } @@ -747,9 +747,9 @@ public static bool TryGetFromExtraFields(List? extraFields { byte[] data = field.Data; aesExtraField = new WinZipAesExtraField( - VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), - AesStrength: data[4], - CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) + vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), + aesStrength: data[4], + compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) ); return true; } @@ -782,9 +782,9 @@ public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData { ReadOnlySpan data = extraFieldData.Slice(offset + 4, fieldSize); aesExtraField = new WinZipAesExtraField( - VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), - AesStrength: data[4], - CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) + vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), + aesStrength: data[4], + compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) ); return true; } diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index dc91630a4dfc59..861fd42e458c82 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -1,3 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Compression; From 67562aeef707cbcf1993b006663ec9b6b1b61fe9 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 6 Apr 2026 12:43:39 +0200 Subject: [PATCH 59/83] address comments --- .../IO/Compression/ZipArchiveEntry.Async.cs | 6 +++ .../System/IO/Compression/ZipArchiveEntry.cs | 39 ++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index dc6d4b36102efa..563c4537ac9bff 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -120,6 +120,9 @@ public async Task OpenAsync(FileAccess access, ReadOnlyMemory pass if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + bool usePassword = IsEncrypted && !password.IsEmpty; switch (_archive.Mode) @@ -164,6 +167,9 @@ public async Task OpenAsync(ReadOnlyMemory password, CancellationT cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + bool usePassword = IsEncrypted && !password.IsEmpty; switch (_archive.Mode) 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 5305eb125f13cb..04bc778d73f82c 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 @@ -23,7 +23,6 @@ public partial class ZipArchiveEntry private ZipVersionNeededValues _versionMadeBySpecification; private ZipVersionNeededValues _versionToExtract; private BitFlagValues _generalPurposeBitFlag; - private readonly bool _isEncrypted; private ZipCompressionMethod _storedCompressionMethod; private DateTimeOffset _lastModified; private long _compressedSize; @@ -72,13 +71,12 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _versionMadeBySpecification = (ZipVersionNeededValues)cd.VersionMadeBySpecification; _versionToExtract = (ZipVersionNeededValues)cd.VersionNeededToExtract; _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; - _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; // Initialize _headerCompressionMethod from the central directory. // For AES entries, this will be 99 (WinZip AES wrapper indicator) and never changes. _headerCompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; // For AES-encrypted entries, the real compression method is stored in the AES extra field (0x9901) // Parse it now so that people can see the actual value before opening the entry. - if (_isEncrypted && cd.AesExtraField.HasValue) + if (IsEncrypted && cd.AesExtraField.HasValue) { WinZipAesExtraField aesField = cd.AesExtraField.Value; // Set the real compression method from the AES extra field @@ -94,7 +92,7 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _ => throw new InvalidDataException(SR.InvalidAesStrength) }; } - else if (_isEncrypted) + else if (IsEncrypted) { // Encrypted but no AES extra field means ZipCrypto Encryption = ZipEncryptionMethod.ZipCrypto; @@ -209,7 +207,7 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) /// /// Gets a value that indicates whether the entry is encrypted. /// - public bool IsEncrypted => _isEncrypted; + public bool IsEncrypted => (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; /// /// Gets the encryption method used to encrypt the entry. @@ -442,6 +440,9 @@ public Stream Open(ReadOnlySpan password) { ThrowIfInvalidArchive(); + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + switch (_archive.Mode) { case ZipArchiveMode.Read: @@ -520,6 +521,9 @@ public Stream Open(FileAccess access, ReadOnlySpan password) if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + bool usePassword = IsEncrypted && !password.IsEmpty; switch (_archive.Mode) @@ -1311,6 +1315,7 @@ private WinZipAesExtraField CreateAesExtraField() { return new WinZipAesExtraField { + VendorVersion = 2, AesStrength = Encryption switch { ZipEncryptionMethod.Aes128 => (byte)1, @@ -1499,41 +1504,37 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { + bool isCreateMode = _archive.Mode == ZipArchiveMode.Create; + if (Encryption == ZipEncryptionMethod.ZipCrypto) { - // Streaming mode for encryption: sizes and CRC unknown upfront - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; - Debug.Assert(_crc32 == 0); } else if (UseAesEncryption()) { _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; - - // Set compression method to 99 (AES indicator) in the header CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; } - // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit - // if we are using the data descriptor, then sizes and crc should be set to 0 in the header - else if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) + else if (isCreateMode && !_archive.ArchiveStream.CanSeek) { _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; - // the crc should not have been set if we are in create mode, but clear it just to be sure Debug.Assert(_crc32 == 0); } - else // if we are not in streaming mode, we have to decide if we want to write zip64 headers + else { - // We are in seekable mode so we will not need to write a data descriptor + // Seekable mode: sizes and CRC are known, no data descriptor needed. _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor; + if (ShouldUseZIP64 #if DEBUG_FORCE_ZIP64 || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) @@ -1543,7 +1544,6 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o compressedSizeTruncated = ZipHelper.Mask32Bit; uncompressedSizeTruncated = ZipHelper.Mask32Bit; - // prepare Zip64 extra field object. If we have one of the sizes, the other must go in there zip64ExtraField = new() { CompressedSize = _compressedSize, @@ -1612,8 +1612,9 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + // When using data descriptors, CRC must be 0 in the local header. + // For AE-2, CRC is always 0 regardless. + uint crcToWrite = (UseAesEncryption() || (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) != 0) ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); From 351d1d1a4859976b127c81bca5903991b929c5ce Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 8 Apr 2026 14:11:04 +0200 Subject: [PATCH 60/83] add unknown zipencryptionmethod value --- .../ref/System.IO.Compression.cs | 1 + .../src/Resources/Strings.resx | 3 ++ .../IO/Compression/ZipArchiveEntry.Async.cs | 6 +++ .../System/IO/Compression/ZipArchiveEntry.cs | 19 +++++++- .../IO/Compression/ZipEncryptionMethod.cs | 6 +++ .../tests/ZipArchive/zip_ReadTests.cs | 47 +++++++++++++++++++ 6 files changed, 80 insertions(+), 2 deletions(-) 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 56ed147b740a04..ac2adaa1c25056 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -153,6 +153,7 @@ public enum ZipCompressionMethod } public enum ZipEncryptionMethod { + Unknown = -1, None = 0, ZipCrypto = 1, Aes128 = 2, diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index e5fc468cc26bfe..7550e003d19031 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -414,4 +414,7 @@ Zip archive encryption is not supported on browser platform. + + The entry's encryption method is not supported. + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 563c4537ac9bff..cca48d222b8eaf 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -399,6 +399,9 @@ private async Task OpenInReadModeGetDataCompressorAsync(long offsetOfCom private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStream, ReadOnlyMemory password, CancellationToken cancellationToken) { + if (Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); @@ -449,6 +452,9 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); + if (loadExistingContent && Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + // Validate password requirement for encrypted entries if (loadExistingContent && IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); 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 04bc778d73f82c..014aa4efa870c7 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 @@ -94,8 +94,15 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) } else if (IsEncrypted) { - // Encrypted but no AES extra field means ZipCrypto - Encryption = ZipEncryptionMethod.ZipCrypto; + if ((_generalPurposeBitFlag & BitFlagValues.StrongEncryption) != 0) + { + Encryption = ZipEncryptionMethod.Unknown; + } + else + { + // Encrypted but no AES extra field means ZipCrypto + Encryption = ZipEncryptionMethod.ZipCrypto; + } CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; } else @@ -214,6 +221,7 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) /// /// /// if the entry is not encrypted; + /// if the entry uses an unsupported encryption method; /// otherwise, the specific encryption method (e.g., , /// , , /// or ). @@ -1017,6 +1025,9 @@ private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) // Creates the appropriate decryption stream for an encrypted entry. private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan password) { + if (Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); @@ -1201,6 +1212,9 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, ReadOnly if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); + if (loadExistingContent && Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + // Validate password requirement for encrypted entries if (loadExistingContent && IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); @@ -2319,6 +2333,7 @@ internal enum BitFlagValues : ushort { IsEncrypted = 0x1, DataDescriptor = 0x8, + StrongEncryption = 0x40, UnicodeFileNameAndComment = 0x800 } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs index 801370e7d7a4e9..ca2f3d1eadd0c8 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs @@ -8,6 +8,12 @@ namespace System.IO.Compression ///
public enum ZipEncryptionMethod { + /// + /// An encryption method that is not supported by this implementation. + /// Attempting to open an entry with this encryption method will throw . + /// + Unknown = -1, + /// /// No Encryption is applied to the entry. /// diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index c2f856dd88bf96..3ed31d549ae5d5 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -919,5 +919,52 @@ public static async Task ReadAfterSeekingPastEnd_ReturnsZeroBytes(bool async) await DisposeStream(async, readStream); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static async Task StrongEncryptionDetectedAsUnknown(bool async) + { + var ms = new MemoryStream(); + ZipArchive createArchive = await CreateZipArchive(async, ms, ZipArchiveMode.Create, leaveOpen: true); + ZipArchiveEntry newEntry = createArchive.CreateEntry("test.txt"); + using (Stream entryStream = async ? await newEntry.OpenAsync() : newEntry.Open()) + { + byte[] data = "hello"u8.ToArray(); + if (async) + await entryStream.WriteAsync(data); + else + entryStream.Write(data, 0, data.Length); + } + await DisposeZipArchive(async, createArchive); + + byte[] zipBytes = ms.ToArray(); + + // Set bit 0 (encrypted) and bit 6 (strong encryption) in both LH and CD general purpose bit flags + const ushort strongEncryptionFlags = 0x01 | 0x40; + + // Local file header: signature at 0, version at 4, bit flags at offset 6 + int lhBitFlagOffset = 6; + BinaryPrimitives.WriteUInt16LittleEndian(zipBytes.AsSpan(lhBitFlagOffset), strongEncryptionFlags); + + // Find central directory (from EOCD at end of file) + // EOCD signature is 0x06054b50, CD offset is at EOCD + 16 + int eocdOffset = zipBytes.Length - 22; // minimal EOCD is 22 bytes with no comment + int cdOffset = BinaryPrimitives.ReadInt32LittleEndian(zipBytes.AsSpan(eocdOffset + 16)); + // CD header: signature at 0, version-made-by at 4 (2 bytes), version-needed at 6 (2 bytes), bit flags at offset 8 + int cdBitFlagOffset = cdOffset + 8; + BinaryPrimitives.WriteUInt16LittleEndian(zipBytes.AsSpan(cdBitFlagOffset), strongEncryptionFlags); + + using var archiveStream = new MemoryStream(zipBytes); + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Read); + ZipArchiveEntry entry = archive.Entries[0]; + + Assert.True(entry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.Unknown, entry.EncryptionMethod); + + Assert.Throws(() => entry.Open("password".AsSpan())); + + await DisposeZipArchive(async, archive); + } } } From d2cb0bd4478737bbc2820467ef287f6b802f5e10 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 8 Apr 2026 16:49:00 +0200 Subject: [PATCH 61/83] change to readonlyspan in openasync methods --- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 2 +- .../tests/ZipFile.Encryption.cs | 19 +- .../ref/System.IO.Compression.cs | 4 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 315 +++++++++++------- .../System/IO/Compression/ZipArchiveEntry.cs | 76 ++--- 5 files changed, 253 insertions(+), 163 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index d2cf07ea6bb709..d3af522eb58da8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -150,7 +150,7 @@ private static async Task ExtractToFileAsync(ZipArchiveEntry source, string dest FileStream fs = new FileStream(extractPath, fileStreamOptions); await using (fs.ConfigureAwait(false)) { - Stream es = await source.OpenAsync(password, cancellationToken: cancellationToken).ConfigureAwait(false); + Stream es = await source.OpenAsync(password.Span, cancellationToken: cancellationToken).ConfigureAwait(false); await using (es.ConfigureAwait(false)) { await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 3dfbade14a34d1..4774b1c9386154 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1363,11 +1363,16 @@ public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) ZipArchiveEntry entry = archive.GetEntry("test.txt"); Assert.NotNull(entry); + if (async) { - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Write, password.AsMemory())); - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.ReadWrite, password.AsMemory())); + await Assert.ThrowsAsync( + () => entry.OpenAsync(FileAccess.Write, password)); + + await Assert.ThrowsAsync( + () => entry.OpenAsync(FileAccess.ReadWrite, password)); } + else { Assert.Throws(() => entry.Open(FileAccess.Write, password)); @@ -1390,7 +1395,7 @@ public async Task Open_FileAccess_CreateMode_InvalidAccess_Throws(bool async) if (async) { // Read access in create mode throws - await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password".AsMemory())); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "password")); // Encryption without password throws Assert.Throws(() => archive.CreateEntry("test_null.txt", (string)null!, ZipEncryptionMethod.Aes256)); Assert.Throws(() => archive.CreateEntry("test_empty.txt", "", ZipEncryptionMethod.Aes256)); @@ -1422,10 +1427,10 @@ public async Task Open_FileAccess_UpdateMode_EncryptedEntry_NoPassword_Throws(bo if (async) { - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.Read, "".AsMemory())); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, null!)); - await Assert.ThrowsAnyAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "".AsMemory())); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, null!)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.Read, "")); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.ReadWrite, null!)); + await Assert.ThrowsAsync(() => entry.OpenAsync(FileAccess.ReadWrite, "")); } else { 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 ac2adaa1c25056..bc0752801e5c8a 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -133,9 +133,9 @@ public void Delete() { } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password) { throw null; } public System.IO.Stream Open(System.ReadOnlySpan password) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlySpan password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.ReadOnlyMemory password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.ReadOnlySpan password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index cca48d222b8eaf..eeb46e05d791ca 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -109,10 +109,9 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell /// /// is not a valid value. /// The requested access is not compatible with the archive's open mode. - /// The entry is not encrypted. /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. /// The ZipArchive that this entry belongs to has been disposed. - public async Task OpenAsync(FileAccess access, ReadOnlyMemory password, CancellationToken cancellationToken = default) + public Task OpenAsync(FileAccess access, ReadOnlySpan password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -131,13 +130,13 @@ public async Task OpenAsync(FileAccess access, ReadOnlyMemory pass if (access != FileAccess.Read) throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); return usePassword - ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) - : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); + ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) + : OpenInReadModeAsync(checkOpenable: true, cancellationToken); case ZipArchiveMode.Create: if (access == FileAccess.Read) throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return OpenInWriteMode(); + return Task.FromResult(OpenInWriteMode()); case ZipArchiveMode.Update: default: @@ -146,23 +145,23 @@ public async Task OpenAsync(FileAccess access, ReadOnlyMemory pass { case FileAccess.Read: return usePassword - ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) - : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); + ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) + : OpenInReadModeAsync(checkOpenable: true, cancellationToken); case FileAccess.Write: return usePassword - ? await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken, password).ConfigureAwait(false) - : await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken).ConfigureAwait(false); + ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: false, password, cancellationToken) + : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken)); case FileAccess.ReadWrite: default: return usePassword - ? await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false) - : await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); + ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: true, password, cancellationToken) + : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken)); } } } - public async Task OpenAsync(ReadOnlyMemory password, CancellationToken cancellationToken = default) + public Task OpenAsync(ReadOnlySpan password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -176,16 +175,16 @@ public async Task OpenAsync(ReadOnlyMemory password, CancellationT { case ZipArchiveMode.Read: return usePassword - ? await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password).ConfigureAwait(false) - : await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); + ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) + : OpenInReadModeAsync(checkOpenable: true, cancellationToken); case ZipArchiveMode.Create: - return OpenInWriteMode(); + return Task.FromResult(OpenInWriteMode()); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); return usePassword - ? await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false) - : await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); + ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: true, password, cancellationToken) + : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken)); } } @@ -207,25 +206,65 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance return _storedOffsetOfCompressedData.Value; } - private async Task GetUncompressedDataAsync(CancellationToken cancellationToken, ReadOnlyMemory password = default) + private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (checkOpenable) + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + + return BuildDecompressionPipeline(compressedStream); + } + + private async Task OpenInUpdateModeAsync(bool loadExistingContent, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (_storedUncompressedData == null) + if (_currentlyOpenForWrite) + throw new IOException(SR.UpdateModeOneStream); + + if (Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + + if (loadExistingContent) + { + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); + } + + _currentlyOpenForWrite = true; + + if (loadExistingContent) { - // this means we have never opened it before + _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken).ConfigureAwait(false); + } + else + { + _storedUncompressedData?.Dispose(); + _storedUncompressedData = new MemoryStream(); + MarkAsModified(); + } + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + + return new WrappedStream(_storedUncompressedData, this, + onClosed: thisRef => thisRef!._currentlyOpenForWrite = false, + notifyEntryOnWrite: true); + } + + private async Task GetUncompressedDataAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_storedUncompressedData is null) + { if (_uncompressedSize > Array.MaxLength) - { throw new InvalidDataException(SR.EntryTooLarge); - } _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { - Stream decompressor = !password.IsEmpty - ? await OpenInReadModeAsync(checkOpenable: false, cancellationToken, password).ConfigureAwait(false) - : await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); + Stream decompressor = await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); await using (decompressor) { @@ -235,11 +274,6 @@ private async Task GetUncompressedDataAsync(CancellationToken canc } catch (InvalidDataException) { - // this is the case where the archive say the entry is deflate, but deflateStream - // throws an InvalidDataException. This property should only be getting accessed in - // Update mode, so we want to make sure _storedUncompressedData stays null so - // that later when we dispose the archive, this entry loads the compressedBytes, and - // copies them straight over await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; _currentlyOpenForWrite = false; @@ -361,127 +395,180 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo throw new InvalidDataException(message); } - private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken, ReadOnlyMemory password = default) + /// + /// Non-async bridge for the password-accepting read path. + /// Derives decryption keys synchronously, then starts the async decryption stream creation + /// and passes the resulting task for later decompression. + /// + private Task OpenInReadModeWithPasswordAsync(bool checkOpenable, ReadOnlySpan password, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); if (checkOpenable) - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); - return await OpenInReadModeGetDataCompressorAsync( - await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false), password, cancellationToken).ConfigureAwait(false); - } + if (Encryption == ZipEncryptionMethod.Unknown) + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); - private async Task OpenInReadModeGetDataCompressorAsync(long offsetOfCompressedData, ReadOnlyMemory password, CancellationToken cancellationToken) - { - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - Stream streamToDecompress; + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); - if (IsEncrypted) + long offset = GetOffsetOfCompressedData(); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + + if (IsAesEncrypted) { - // Use the shared helper that handles key caching - streamToDecompress = await WrapWithDecryptionIfNeededAsync(compressedStream, password, cancellationToken).ConfigureAwait(false); + if (OperatingSystem.IsBrowser()) + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + + int keySizeBits = GetAesKeySizeBits(Encryption); + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); + compressedStream.Seek(-saltSize, SeekOrigin.Current); + WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, salt, keySizeBits); + return OpenWithAesDecryptionAsync(compressedStream, aesKeys, cancellationToken); } - else + + if (IsZipCryptoEncrypted) { - streamToDecompress = compressedStream; + byte checkByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); + return OpenWithZipCryptoDecryptionAsync(compressedStream, keys, checkByte, cancellationToken); } - // Get decompressed stream - Stream decompressedStream = GetDataDecompressor(streamToDecompress); + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + private async Task OpenWithZipCryptoDecryptionAsync(Stream compressedStream, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) + { + Stream decrypted = await ZipCryptoStream.CreateAsync(compressedStream, keys, checkByte, encrypting: false, cancellationToken).ConfigureAwait(false); + + return BuildDecompressionPipeline(decrypted); + } + + private async Task OpenWithAesDecryptionAsync(Stream compressedStream, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) + { + if (OperatingSystem.IsBrowser()) + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - // AE-2 encrypted entries store CRC as 0 and use HMAC for integrity instead, - // so skip CRC validation for those. AE-1 entries store a valid CRC. - if (IsAesEncrypted && _aeVersion == 2) - return decompressedStream; + Stream decrypted = await WinZipAesStream.CreateAsync( + baseStream: compressedStream, + keyMaterial: aesKeys, + totalStreamSize: _compressedSize, + encrypting: false, + cancellationToken: cancellationToken).ConfigureAwait(false); - return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); + return BuildDecompressionPipeline(decrypted); } - private async Task WrapWithDecryptionIfNeededAsync(Stream compressedStream, ReadOnlyMemory password, CancellationToken cancellationToken) + /// + /// Non-async bridge for the password-accepting update path. + /// Derives decryption and re-encryption keys synchronously, then delegates to the async core. + /// + private Task OpenInUpdateModeWithPasswordAsync(bool loadExistingContent, ReadOnlySpan password, CancellationToken cancellationToken) { + if (_currentlyOpenForWrite) + throw new IOException(SR.UpdateModeOneStream); + if (Encryption == ZipEncryptionMethod.Unknown) throw new NotSupportedException(SR.UnsupportedEncryptionMethod); - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); + // Encrypted entries always require a password for re-encryption, + // even when discarding existing content (write-only access). + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); - bool isAesEncrypted = (ushort)_headerCompressionMethod == WinZipAesMethod; + // Derive re-encryption key material while the password span is still valid + if (IsEncrypted) + SetupEncryptionKeyMaterial(password); - if (!isAesEncrypted && IsZipCryptoEncrypted()) - { - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password.Span); - return await ZipCryptoStream.CreateAsync(compressedStream, keyMaterial, expectedCheckByte, encrypting: false, cancellationToken).ConfigureAwait(false); - } - else if (isAesEncrypted) + if (loadExistingContent && IsEncrypted) { - if (OperatingSystem.IsBrowser()) + ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); + + long offset = GetOffsetOfCompressedData(); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + + if (IsAesEncrypted) { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + if (OperatingSystem.IsBrowser()) + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + + int keySizeBits = GetAesKeySizeBits(Encryption); + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); + compressedStream.Seek(-saltSize, SeekOrigin.Current); + WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, salt, keySizeBits); + return DecryptAndStoreForUpdateWithAesAsync(compressedStream, aesKeys, cancellationToken); } - int keySizeBits = GetAesKeySizeBits(Encryption); + if (IsZipCryptoEncrypted) + { + byte checkByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); + return DecryptAndStoreForUpdateWithZipCryptoAsync(compressedStream, keys, checkByte, cancellationToken); + } - // Read salt from stream to derive keys - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - await compressedStream.ReadExactlyAsync(salt, cancellationToken).ConfigureAwait(false); + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } - // Seek back so WinZipAesStream can read the header (salt + password verifier) - compressedStream.Seek(-saltSize, SeekOrigin.Current); + return CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent, cancellationToken)); + } - // Derive key material from the provided password - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password.Span, salt, keySizeBits); + private static async Task CastToStreamAsync(Task task) => await task.ConfigureAwait(false); - return await WinZipAesStream.CreateAsync( - baseStream: compressedStream, - keyMaterial: keyMaterial, - totalStreamSize: _compressedSize, - encrypting: false, - leaveOpen: false, - cancellationToken: cancellationToken).ConfigureAwait(false); - } + private async Task DecryptAndStoreForUpdateWithZipCryptoAsync(Stream compressedStream, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) + { + Stream decrypted = await ZipCryptoStream.CreateAsync(compressedStream, keys, checkByte, encrypting: false, cancellationToken).ConfigureAwait(false); - // Not encrypted - return as-is - return compressedStream; + return await StoreDecryptedDataForUpdateAsync(decrypted, cancellationToken).ConfigureAwait(false); } - private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default, ReadOnlyMemory password = default) + private async Task DecryptAndStoreForUpdateWithAesAsync(Stream compressedStream, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - if (_currentlyOpenForWrite) - throw new IOException(SR.UpdateModeOneStream); - - if (loadExistingContent && Encryption == ZipEncryptionMethod.Unknown) - throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + if (OperatingSystem.IsBrowser()) + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - // Validate password requirement for encrypted entries - if (loadExistingContent && IsEncrypted && password.IsEmpty) - throw new ArgumentException(SR.PasswordRequired, nameof(password)); + Stream decrypted = await WinZipAesStream.CreateAsync( + baseStream: compressedStream, + keyMaterial: aesKeys, + totalStreamSize: _compressedSize, + encrypting: false, + cancellationToken: cancellationToken).ConfigureAwait(false); - if (loadExistingContent) - { - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); - } + return await StoreDecryptedDataForUpdateAsync(decrypted, cancellationToken).ConfigureAwait(false); + } + /// + /// Decompresses a decrypted stream and stores the result in memory for update mode. + /// + private async Task StoreDecryptedDataForUpdateAsync(Stream decryptedStream, CancellationToken cancellationToken) + { _currentlyOpenForWrite = true; - if (loadExistingContent) - { - _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken, password).ConfigureAwait(false); + if (_uncompressedSize > Array.MaxLength) + throw new InvalidDataException(SR.EntryTooLarge); - // For encrypted entries, set up key material for re-encryption - if (IsEncrypted) + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); + + Stream decompressed = BuildDecompressionPipeline(decryptedStream); + + await using (decompressed) + { + try { - SetupEncryptionKeyMaterial(password.Span); + await decompressed.CopyToAsync(_storedUncompressedData, cancellationToken).ConfigureAwait(false); + } + catch (InvalidDataException) + { + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + _derivedZipCryptoKeyMaterial = null; + _derivedAesKeyMaterial = null; + throw; } - } - else - { - _storedUncompressedData?.Dispose(); - _storedUncompressedData = new MemoryStream(); - // Opening with loadExistingContent: false discards existing content, which is a modification - MarkAsModified(); } _storedUncompressedData.Seek(0, SeekOrigin.Begin); @@ -736,11 +823,14 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; ZipEncryptionMethod savedEncryption = Encryption; + ZipCompressionMethod savedCompressionMethod = CompressionMethod; - // For AES entries: clear Encryption so WriteLocalFileHeaderAsync doesn't create a new + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; Encryption = ZipEncryptionMethod.None; } @@ -749,6 +839,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // Restore original state _generalPurposeBitFlag = savedFlags; Encryption = savedEncryption; + CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) 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 014aa4efa870c7..be2ac737274215 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 @@ -1000,10 +1000,7 @@ private byte CalculateZipCryptoCheckByte() return (byte)((ZipHelper.DateTimeToDosTime(_lastModified.DateTime) >> 8) & 0xFF); } - private bool IsZipCryptoEncrypted() - { - return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != WinZipAesMethod; - } + private bool IsZipCryptoEncrypted => (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != WinZipAesMethod; private bool UseAesEncryption() { @@ -1022,7 +1019,9 @@ private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) }; } - // Creates the appropriate decryption stream for an encrypted entry. + /// + /// Creates the appropriate decryption stream for an encrypted entry. + /// private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan password) { if (Encryption == ZipEncryptionMethod.Unknown) @@ -1031,34 +1030,18 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan< if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); - bool isAesEncrypted = IsAesEncrypted; - - if (!isAesEncrypted && IsZipCryptoEncrypted()) - { - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); - return ZipCryptoStream.Create(compressedStream, keyMaterial, expectedCheckByte, encrypting: false); - } - else if (isAesEncrypted) + if (IsAesEncrypted) { if (OperatingSystem.IsBrowser()) - { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } int keySizeBits = GetAesKeySizeBits(Encryption); - - // Read salt from stream to derive keys int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); byte[] salt = new byte[saltSize]; compressedStream.ReadExactly(salt); - - // Seek back so WinZipAesStream can read the header (salt + password verifier) compressedStream.Seek(-saltSize, SeekOrigin.Current); - // Derive key material from the provided password WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); - return WinZipAesStream.Create( baseStream: compressedStream, keyMaterial: keyMaterial, @@ -1066,8 +1049,14 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan< encrypting: false); } - // Not encrypted - return as-is - return compressedStream; + if (IsZipCryptoEncrypted) + { + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); + return ZipCryptoStream.Create(compressedStream, keyMaterial, expectedCheckByte, encrypting: false); + } + + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); } private Stream GetDataDecompressor(Stream compressedStreamToRead) @@ -1112,7 +1101,6 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read if (IsEncrypted) { - // Use the shared helper that handles key caching streamToDecompress = WrapWithDecryptionIfNeeded(compressedStream, password); } else @@ -1120,11 +1108,19 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read streamToDecompress = compressedStream; } - // Get decompressed stream + return BuildDecompressionPipeline(streamToDecompress); + } + + /// + /// Wraps a (possibly decrypted) stream with decompression and CRC validation. + /// Shared by both sync and async read paths. + /// + private Stream BuildDecompressionPipeline(Stream streamToDecompress) + { Stream decompressedStream = GetDataDecompressor(streamToDecompress); - // AE-2 encrypted entries store CRC as 0 and use HMAC for integrity instead, - // so skip CRC validation for those. AE-1 entries store a valid CRC. + // AE-2 encrypted entries store CRC as 0 so skip CRC validation for those. + // AE-1 version entries store a valid CRC. if (IsAesEncrypted && _aeVersion == 2) return decompressedStream; @@ -1212,11 +1208,12 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, ReadOnly if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); - if (loadExistingContent && Encryption == ZipEncryptionMethod.Unknown) + if (Encryption == ZipEncryptionMethod.Unknown) throw new NotSupportedException(SR.UnsupportedEncryptionMethod); - // Validate password requirement for encrypted entries - if (loadExistingContent && IsEncrypted && password.IsEmpty) + // Encrypted entries always require a password for re-encryption, + // even when discarding existing content (write-only access). + if (IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); if (loadExistingContent) @@ -1226,15 +1223,15 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, ReadOnly _currentlyOpenForWrite = true; + // Set up re-encryption key material so that the rewritten entry has valid encryption headers. + if (IsEncrypted) + { + SetupEncryptionKeyMaterial(password); + } + if (loadExistingContent) { _storedUncompressedData = GetUncompressedData(password); - - // For encrypted entries, set up key material for re-encryption - if (IsEncrypted) - { - SetupEncryptionKeyMaterial(password); - } } else { @@ -1273,7 +1270,7 @@ internal void MarkAsModified() private void SetupEncryptionKeyMaterial(ReadOnlySpan password) { // Derive and save key material for re-encryption - if (IsZipCryptoEncrypted()) + if (IsZipCryptoEncrypted) { _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); Encryption = ZipEncryptionMethod.ZipCrypto; @@ -1290,9 +1287,6 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); // Encryption is already set from constructor (parsed from central directory AES extra field) } - - // Reset CRC - it will be recalculated when writing - _crc32 = 0; } /// From d50e6b17a5a9d6086f6aff98cc6d1db8b790865a Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 9 Apr 2026 12:55:35 +0200 Subject: [PATCH 62/83] address comments --- .../System/IO/Compression/WinZipAesStream.cs | 3 +- .../System/IO/Compression/ZipArchive.Async.cs | 6 + .../src/System/IO/Compression/ZipArchive.cs | 6 + .../IO/Compression/ZipArchiveEntry.Async.cs | 161 ++++++++++++++---- .../System/IO/Compression/ZipArchiveEntry.cs | 83 ++++++++- 5 files changed, 217 insertions(+), 42 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 3184049f1084ec..211727700fb417 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -616,6 +616,8 @@ protected override void Dispose(bool disposing) return; } + _disposed = true; + if (disposing) { try @@ -652,7 +654,6 @@ protected override void Dispose(bool disposing) } } - _disposed = true; base.Dispose(disposing); } 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..0a4a380f37d6fe 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 @@ -239,6 +239,12 @@ private async Task ReadCentralDirectoryAsync(CancellationToken cancellationToken { break; } + + ZipArchiveEntry lastEntry = _entries[_entries.Count - 1]; + if (lastEntry.IsEncrypted) + { + await lastEntry.ReadEncryptionSaltIfNeededAsync(cancellationToken).ConfigureAwait(false); + } } ReadCentralDirectoryEndOfOuterLoopWork(ref currPosition, sizedFileBuffer.Span); 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 c52e71b8fa3719..769937497ac130 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 @@ -665,6 +665,12 @@ private void ReadCentralDirectory() { break; } + + ZipArchiveEntry lastEntry = _entries[_entries.Count - 1]; + if (lastEntry.IsEncrypted) + { + lastEntry.ReadEncryptionSaltIfNeeded(); + } } ReadCentralDirectoryEndOfOuterLoopWork(ref currPosition, sizedFileBuffer); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index eeb46e05d791ca..bd26f67390e046 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -206,6 +207,42 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance return _storedOffsetOfCompressedData.Value; } + /// + /// Asynchronously reads and caches the AES encryption salt from the local file data area. + /// Called during central directory parsing for AES-encrypted entries so that + /// the salt is available for key derivation without additional I/O at open time. + /// + internal async Task ReadEncryptionSaltIfNeededAsync(CancellationToken cancellationToken) + { + if (!IsAesEncrypted || !_originallyInArchive || OperatingSystem.IsBrowser()) + { + return; + } + + long savedPosition = _archive.ArchiveStream.Position; + try + { + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + _archive.ArchiveStream.Seek(offset, SeekOrigin.Begin); + + int keySizeBits = GetAesKeySizeBits(Encryption); + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + _aesSalt = new byte[saltSize]; + await _archive.ArchiveStream.ReadExactlyAsync(_aesSalt, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) + { + // These are the only exceptions GetOffsetOfCompressedDataAsync() and ReadExactlyAsync() + // can throw for corrupt or truncated data. Swallow them here and defer the error + // to when the entry is actually opened. + _aesSalt = null; + } + finally + { + _archive.ArchiveStream.Seek(savedPosition, SeekOrigin.Begin); + } + } + private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -396,59 +433,77 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo } /// - /// Non-async bridge for the password-accepting read path. - /// Derives decryption keys synchronously, then starts the async decryption stream creation - /// and passes the resulting task for later decompression. + /// Accepts a password, derives decryption keys (CPU-only), + /// and delegates all I/O to fully async helper methods. /// private Task OpenInReadModeWithPasswordAsync(bool checkOpenable, ReadOnlySpan password, CancellationToken cancellationToken) { - if (checkOpenable) - ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); - if (Encryption == ZipEncryptionMethod.Unknown) + { throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } if (password.IsEmpty) + { throw new InvalidDataException(SR.PasswordRequired); - - long offset = GetOffsetOfCompressedData(); - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + } if (IsAesEncrypted) { if (OperatingSystem.IsBrowser()) + { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + if (_aesSalt is null) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } int keySizeBits = GetAesKeySizeBits(Encryption); - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - compressedStream.ReadExactly(salt); - compressedStream.Seek(-saltSize, SeekOrigin.Current); - WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, salt, keySizeBits); - return OpenWithAesDecryptionAsync(compressedStream, aesKeys, cancellationToken); + WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); + return OpenWithAesDecryptionAsync(checkOpenable, aesKeys, cancellationToken); } if (IsZipCryptoEncrypted) { byte checkByte = CalculateZipCryptoCheckByte(); ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); - return OpenWithZipCryptoDecryptionAsync(compressedStream, keys, checkByte, cancellationToken); + return OpenWithZipCryptoDecryptionAsync(checkOpenable, keys, checkByte, cancellationToken); } throw new NotSupportedException(SR.UnsupportedEncryptionMethod); } - private async Task OpenWithZipCryptoDecryptionAsync(Stream compressedStream, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) + private async Task OpenWithZipCryptoDecryptionAsync(bool checkOpenable, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) { + if (checkOpenable) + { + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + } + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + Stream decrypted = await ZipCryptoStream.CreateAsync(compressedStream, keys, checkByte, encrypting: false, cancellationToken).ConfigureAwait(false); return BuildDecompressionPipeline(decrypted); } - private async Task OpenWithAesDecryptionAsync(Stream compressedStream, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) + private async Task OpenWithAesDecryptionAsync(bool checkOpenable, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) { if (OperatingSystem.IsBrowser()) + { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + if (checkOpenable) + { + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + } + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); Stream decrypted = await WinZipAesStream.CreateAsync( baseStream: compressedStream, @@ -461,52 +516,58 @@ private async Task OpenWithAesDecryptionAsync(Stream compressedStream, W } /// - /// Non-async bridge for the password-accepting update path. - /// Derives decryption and re-encryption keys synchronously, then delegates to the async core. + /// Accepts a password, derives decryption and re-encryption keys (CPU-only), + /// and delegates all I/O to fully async helper methods. /// private Task OpenInUpdateModeWithPasswordAsync(bool loadExistingContent, ReadOnlySpan password, CancellationToken cancellationToken) { if (_currentlyOpenForWrite) + { throw new IOException(SR.UpdateModeOneStream); + } if (Encryption == ZipEncryptionMethod.Unknown) + { throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } // Encrypted entries always require a password for re-encryption, // even when discarding existing content (write-only access). if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } - // Derive re-encryption key material while the password span is still valid + // Derive re-encryption key material while the password span is still valid. if (IsEncrypted) + { SetupEncryptionKeyMaterial(password); + } if (loadExistingContent && IsEncrypted) { - ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); - - long offset = GetOffsetOfCompressedData(); - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); - if (IsAesEncrypted) { if (OperatingSystem.IsBrowser()) + { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + if (_aesSalt is null) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } int keySizeBits = GetAesKeySizeBits(Encryption); - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - compressedStream.ReadExactly(salt); - compressedStream.Seek(-saltSize, SeekOrigin.Current); - WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, salt, keySizeBits); - return DecryptAndStoreForUpdateWithAesAsync(compressedStream, aesKeys, cancellationToken); + WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); + return DecryptAndStoreForUpdateWithAesAsync(aesKeys, cancellationToken); } if (IsZipCryptoEncrypted) { byte checkByte = CalculateZipCryptoCheckByte(); ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); - return DecryptAndStoreForUpdateWithZipCryptoAsync(compressedStream, keys, checkByte, cancellationToken); + return DecryptAndStoreForUpdateWithZipCryptoAsync(keys, checkByte, cancellationToken); } throw new NotSupportedException(SR.UnsupportedEncryptionMethod); @@ -517,17 +578,29 @@ private Task OpenInUpdateModeWithPasswordAsync(bool loadExistingContent, private static async Task CastToStreamAsync(Task task) => await task.ConfigureAwait(false); - private async Task DecryptAndStoreForUpdateWithZipCryptoAsync(Stream compressedStream, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) + private async Task DecryptAndStoreForUpdateWithZipCryptoAsync(ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) { + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + Stream decrypted = await ZipCryptoStream.CreateAsync(compressedStream, keys, checkByte, encrypting: false, cancellationToken).ConfigureAwait(false); return await StoreDecryptedDataForUpdateAsync(decrypted, cancellationToken).ConfigureAwait(false); } - private async Task DecryptAndStoreForUpdateWithAesAsync(Stream compressedStream, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) + private async Task DecryptAndStoreForUpdateWithAesAsync(WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) { if (OperatingSystem.IsBrowser()) + { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); Stream decrypted = await WinZipAesStream.CreateAsync( baseStream: compressedStream, @@ -836,6 +909,24 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); + // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag + // (because Encryption was temporarily set to None and the stream is seekable). + // If the original entry had a data descriptor, patch the general-purpose bit + // flags in the already-written local header to match, so the header on disk + // is consistent with the data descriptor we conditionally write below. + if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && + (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + { + long currentPos = _archive.ArchiveStream.Position; + _archive.ArchiveStream.Seek( + _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, + SeekOrigin.Begin); + byte[] flagBytes = new byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); + await _archive.ArchiveStream.WriteAsync(flagBytes, cancellationToken).ConfigureAwait(false); + _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + } + // Restore original state _generalPurposeBitFlag = savedFlags; Encryption = savedEncryption; 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 be2ac737274215..30fabc47e8e4dc 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 @@ -53,6 +53,9 @@ public partial class ZipArchiveEntry // Only one of these is set at a time, depending on the encryption method. private ZipCryptoKeys? _derivedZipCryptoKeyMaterial; private WinZipAesKeyMaterial? _derivedAesKeyMaterial; + // Pre-read AES salt from the local file data area during central directory parsing. + // This allows async open methods to derive keys without synchronous I/O. + private byte[]? _aesSalt; internal const ushort WinZipAesMethod = 99; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -616,6 +619,42 @@ internal long GetOffsetOfCompressedData() return _storedOffsetOfCompressedData.Value; } + /// + /// Reads and caches the AES encryption salt from the local file data area. + /// Called during central directory parsing for AES-encrypted entries so that + /// the salt is available for key derivation without additional I/O at open time. + /// + internal void ReadEncryptionSaltIfNeeded() + { + if (!IsAesEncrypted || !_originallyInArchive || OperatingSystem.IsBrowser()) + { + return; + } + + long savedPosition = _archive.ArchiveStream.Position; + try + { + long offset = GetOffsetOfCompressedData(); + _archive.ArchiveStream.Seek(offset, SeekOrigin.Begin); + + int keySizeBits = GetAesKeySizeBits(Encryption); + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + _aesSalt = new byte[saltSize]; + _archive.ArchiveStream.ReadExactly(_aesSalt); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) + { + // These are the only exceptions GetOffsetOfCompressedData() and ReadExactly() + // can throw for corrupt or truncated data. Swallow them here and defer the error + // to when the entry is actually opened. + _aesSalt = null; + } + finally + { + _archive.ArchiveStream.Seek(savedPosition, SeekOrigin.Begin); + } + } + private MemoryStream GetUncompressedData(ReadOnlySpan password = default) { if (_storedUncompressedData == null) @@ -1021,27 +1060,34 @@ private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) /// /// Creates the appropriate decryption stream for an encrypted entry. + /// For AES entries, uses the salt that was pre-read during central directory parsing. /// private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan password) { if (Encryption == ZipEncryptionMethod.Unknown) + { throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } if (password.IsEmpty) + { throw new InvalidDataException(SR.PasswordRequired); + } if (IsAesEncrypted) { if (OperatingSystem.IsBrowser()) + { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } - int keySizeBits = GetAesKeySizeBits(Encryption); - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - compressedStream.ReadExactly(salt); - compressedStream.Seek(-saltSize, SeekOrigin.Current); + if (_aesSalt is null) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } - WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + int keySizeBits = GetAesKeySizeBits(Encryption); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); return WinZipAesStream.Create( baseStream: compressedStream, keyMaterial: keyMaterial, @@ -1296,7 +1342,14 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) internal void PrepareEncryption(ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) { if (password.IsEmpty) + { throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } + + if (encryptionMethod is ZipEncryptionMethod.None or ZipEncryptionMethod.Unknown) + { + throw new ArgumentOutOfRangeException(nameof(encryptionMethod), SR.EncryptionNotSpecified); + } Encryption = encryptionMethod; @@ -1803,6 +1856,24 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); + // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag + // (because Encryption was temporarily set to None and the stream is seekable). + // If the original entry had a data descriptor, patch the general-purpose bit + // flags in the already-written local header to match, so the header on disk + // is consistent with the data descriptor we conditionally write below. + if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && + (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + { + long currentPos = _archive.ArchiveStream.Position; + _archive.ArchiveStream.Seek( + _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, + SeekOrigin.Begin); + Span flagBytes = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); + _archive.ArchiveStream.Write(flagBytes); + _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + } + // Restore original state _generalPurposeBitFlag = savedFlags; Encryption = savedEncryption; From b6f9b9d95c0cebe0492a2581a46093a28e9efe9d Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 17 Apr 2026 13:24:29 +0200 Subject: [PATCH 63/83] fix datadscriptor size bug --- .../IO/Compression/ZipArchiveEntry.Async.cs | 8 ++--- .../System/IO/Compression/ZipArchiveEntry.cs | 35 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 304788cf57b841..d31b7a3a9d7c49 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -760,7 +760,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can { // Write local file header first (with encryption flag set) // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header - await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); // Record position before encryption data long startPosition = _archive.ArchiveStream.Position; @@ -810,7 +810,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // 3. Keep CompressionMethod = Aes for central directory // WriteLocalFileHeaderAsync will set CompressionMethod = Aes - await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + bool usedZip64InLH = await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); // Record position before encryption data long startPosition = _archive.ArchiveStream.Position; @@ -861,8 +861,8 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // (includes salt + password verifier + encrypted data + HMAC) _compressedSize = _archive.ArchiveStream.Position - startPosition; - // Write data descriptor since we used streaming mode - await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + // Patch CRC and sizes back into the local header + await WriteCrcAndSizesInLocalHeaderAsync(usedZip64InLH, cancellationToken).ConfigureAwait(false); await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; 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 41f47b4b7762f8..460bdaa7488aaf 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 @@ -818,8 +818,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 { long centralDirectoryHeaderLength = ZipCentralDirectoryFileHeader.FieldLocations.DynamicData + _storedEntryNameBytes.Length - + (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) - + currExtraFieldDataLength + + extraFieldLength + _fileComment.Length; _archive.ArchiveStream.Seek(centralDirectoryHeaderLength, SeekOrigin.Current); @@ -1589,7 +1588,6 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, b else if (UseAesEncryption()) { _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; @@ -1665,14 +1663,9 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, b // If it's new or changed, write the header. if (_originallyInArchive && Changes == ZipArchive.ChangeState.Unchanged && !forceWrite) { - _archive.ArchiveStream.Seek(ZipLocalFileHeader.SizeOfLocalHeader + _storedEntryNameBytes.Length, SeekOrigin.Current); + _archive.ArchiveStream.Seek(ZipLocalFileHeader.SizeOfLocalHeader + _storedEntryNameBytes.Length + extraFieldLength, SeekOrigin.Current); - if (zip64ExtraField != null) - { - _archive.ArchiveStream.Seek(zip64ExtraField.TotalSize, SeekOrigin.Current); - } - _archive.ArchiveStream.Seek(currExtraFieldDataLength, SeekOrigin.Current); return false; } @@ -1694,7 +1687,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, b zip64ExtraField = new() { CompressedSize = 0, UncompressedSize = 0 }; } } - else + else if (Encryption != ZipEncryptionMethod.ZipCrypto) { _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor; } @@ -1813,7 +1806,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } - WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + bool usedZip64InLH = WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); long startPosition = _archive.ArchiveStream.Position; @@ -1848,7 +1841,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _compressedSize = _archive.ArchiveStream.Position - startPosition; - WriteDataDescriptor(); + WriteCrcAndSizesInLocalHeader(usedZip64InLH); _storedUncompressedData.Dispose(); _storedUncompressedData = null; @@ -2385,8 +2378,17 @@ protected override void Dispose(bool disposing) { // go back and finish writing if (_entry._archive.ArchiveStream.CanSeek) + { // finish writing local header if we have seek capabilities _entry.WriteCrcAndSizesInLocalHeader(_usedZip64inLH); + + // ZipCrypto entries retain DataDescriptor for check byte correctness; + // write the trailing descriptor so the archive is consistent. + if ((_entry._generalPurposeBitFlag & BitFlagValues.DataDescriptor) != 0) + { + _entry.WriteDataDescriptor(); + } + } else // write out data descriptor if we don't have seek capabilities _entry.WriteDataDescriptor(); @@ -2422,8 +2424,17 @@ public override async ValueTask DisposeAsync() { // go back and finish writing if (_entry._archive.ArchiveStream.CanSeek) + { // finish writing local header if we have seek capabilities await _entry.WriteCrcAndSizesInLocalHeaderAsync(_usedZip64inLH, cancellationToken: default).ConfigureAwait(false); + + // ZipCrypto entries retain DataDescriptor for check byte correctness; + // write the trailing descriptor so the archive is consistent. + if ((_entry._generalPurposeBitFlag & BitFlagValues.DataDescriptor) != 0) + { + await _entry.WriteDataDescriptorAsync(cancellationToken: default).ConfigureAwait(false); + } + } else // write out data descriptor if we don't have seek capabilities await _entry.WriteDataDescriptorAsync(cancellationToken: default).ConfigureAwait(false); From 48e53362dd3013da235c6f2bace688e6f9677fe4 Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:46:13 +0200 Subject: [PATCH 64/83] Update src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/System/IO/Compression/ZipArchiveEntry.Async.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index d31b7a3a9d7c49..586c7d9e27f996 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -104,7 +104,7 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell /// The allowed values depend on the : /// /// : Only is allowed. - /// : Not supported - use the overload with encryption method. + /// : is allowed; is not allowed. The is only used when decrypting existing encrypted entries and is not used when opening a newly created entry for writing. /// : All values are allowed for encrypted entries. /// /// From b9e5a7578c9db7e1753b0c5914773f719ec7bbb3 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 22 Apr 2026 12:19:46 +0200 Subject: [PATCH 65/83] address copilot feedback --- .../src/Resources/Strings.resx | 8 ++++---- .../IO/Compression/ZipArchiveEntry.Async.cs | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index 7550e003d19031..6992d195e8f64b 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -388,7 +388,7 @@ A password is required for encrypted entries. - Empty password was provided for encrypting this entry + A non empty password is required for creating an encrypted entry. Invalid AES strength value. @@ -403,13 +403,13 @@ The decompressed data length does not match the expected value from the archive. - Encryption type is not specified + Encryption type is not specified. - Encryption Method should not be specified in Read Mode + Encryption method should not be specified in read Mode. - Encryption Method should not be specified in Update Mode + Encryption method should not be specified in update Mode. Zip archive encryption is not supported on browser platform. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 586c7d9e27f996..6e8a431eb317a6 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -161,7 +161,20 @@ public Task OpenAsync(FileAccess access, ReadOnlySpan password, Ca } } - + /// + /// Asynchronously opens the entry and uses the specified password to decrypt it if it is encrypted. + /// If the archive that the entry belongs to was opened in Read mode, the returned stream will be readable, and it may or may not be seekable. If Create mode, the returned stream will be writable and not seekable. If Update mode, the returned stream will be readable, writable, seekable, and support . + /// + /// The password used to decrypt the encrypted entry. + /// The token to monitor for cancellation requests. + /// A task whose result is a stream that represents the contents of the entry. + /// + /// If the entry is not encrypted, is ignored. + /// + /// The entry is encrypted and is empty. + /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in , and this entry has already been written to once. + /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. + /// The that this entry belongs to has been disposed. public Task OpenAsync(ReadOnlySpan password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); From 8be1b7b56c311c7511df94a1ef8b88aa3af91db9 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 24 Apr 2026 14:18:36 +0200 Subject: [PATCH 66/83] address comments and reduce duplication --- .../IO/Compression/ZipFile.Create.Async.cs | 54 +--- .../System/IO/Compression/ZipFile.Create.cs | 49 +-- .../IO/Compression/ZipFile.Extract.Async.cs | 10 +- ...pFileExtensions.ZipArchive.Create.Async.cs | 25 +- .../ZipFileExtensions.ZipArchive.Create.cs | 18 +- ...FileExtensions.ZipArchive.Extract.Async.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 15 +- .../src/Resources/Strings.resx | 10 +- .../IO/Compression/WinZipAesKeyMaterial.cs | 4 +- .../System/IO/Compression/WinZipAesStream.cs | 100 +++---- .../IO/Compression/ZipArchiveEntry.Async.cs | 280 +++++++----------- .../System/IO/Compression/ZipArchiveEntry.cs | 128 +++----- .../tests/WinZipAesStreamConformanceTests.cs | 4 +- 13 files changed, 214 insertions(+), 485 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs index 2999d2bea82b09..8d2bd86ee2427f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs @@ -495,7 +495,7 @@ private static async Task DoCreateFromDirectoryAsync(string sourceDirectoryName, ZipArchive archive = await OpenAsync(destinationArchiveFileName, ZipArchiveMode.Create, entryNameEncoding, cancellationToken).ConfigureAwait(false); await using (archive) { - await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken).ConfigureAwait(false); + await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken: cancellationToken).ConfigureAwait(false); } } @@ -509,12 +509,13 @@ private static async Task DoCreateFromDirectoryAsync(string sourceDirectoryName, ZipArchive archive = await ZipArchive.CreateAsync(destination, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); await using (archive) { - await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken).ConfigureAwait(false); + await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken: cancellationToken).ConfigureAwait(false); } } private static async Task CreateZipArchiveFromDirectoryAsync(string sourceDirectoryName, ZipArchive archive, - CompressionLevel? compressionLevel, bool includeBaseDirectory, CancellationToken cancellationToken) + CompressionLevel? compressionLevel, bool includeBaseDirectory, + ReadOnlyMemory password = default, ZipEncryptionMethod encryptionMethod = ZipEncryptionMethod.None, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -529,53 +530,8 @@ private static async Task CreateZipArchiveFromDirectoryAsync(string sourceDirect { case CreateEntryType.File: { - // Create entry for file: string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); - await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, cancellationToken).ConfigureAwait(false); - } - break; - case CreateEntryType.Directory: - if (ArchivingUtils.IsDirEmpty(fullPath)) - { - // Create entry marking an empty dir: - // FullName never returns a directory separator character on the end, - // but Zip archives require it to specify an explicit directory: - string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length), appendPathSeparator: true); - archive.CreateEntry(entryName); - } - break; - default: - throw new IOException(SR.Format(SR.ZipUnsupportedFile, fullPath)); - } - } - - FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); - } - - private static async Task CreateZipArchiveFromDirectoryAsync(string sourceDirectoryName, ZipArchive archive, - CompressionLevel compressionLevel, bool includeBaseDirectory, - ReadOnlyMemory password, ZipEncryptionMethod encryptionMethod, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - (bool directoryIsEmpty, string basePath, DirectoryInfo di, FileSystemEnumerable<(string, CreateEntryType)> fse) = - InitializeCreateZipArchiveFromDirectory(sourceDirectoryName, includeBaseDirectory); - - bool hasEncryption = !password.IsEmpty && encryptionMethod != ZipEncryptionMethod.None; - - foreach ((string fullPath, CreateEntryType type) in fse) - { - directoryIsEmpty = false; - - switch (type) - { - case CreateEntryType.File: - { - string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); - if (hasEncryption) - await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, password, encryptionMethod, cancellationToken).ConfigureAwait(false); - else - await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, cancellationToken).ConfigureAwait(false); + await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, password, encryptionMethod, cancellationToken).ConfigureAwait(false); } break; case CreateEntryType.Directory: diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs index 4daf0e9090bd75..36b11871eefa8c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs @@ -464,7 +464,8 @@ private static void DoCreateFromDirectory(string sourceDirectoryName, Stream des } private static void CreateZipArchiveFromDirectory(string sourceDirectoryName, ZipArchive archive, - CompressionLevel? compressionLevel, bool includeBaseDirectory) + CompressionLevel? compressionLevel, bool includeBaseDirectory, + ReadOnlySpan password = default, ZipEncryptionMethod encryptionMethod = ZipEncryptionMethod.None) { (bool directoryIsEmpty, string basePath, DirectoryInfo di, FileSystemEnumerable<(string, CreateEntryType)> fse) = InitializeCreateZipArchiveFromDirectory(sourceDirectoryName, includeBaseDirectory); @@ -477,52 +478,8 @@ private static void CreateZipArchiveFromDirectory(string sourceDirectoryName, Zi { case CreateEntryType.File: { - // Create entry for file: string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); - ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel); - } - break; - case CreateEntryType.Directory: - if (ArchivingUtils.IsDirEmpty(fullPath)) - { - // Create entry marking an empty dir: - // FullName never returns a directory separator character on the end, - // but Zip archives require it to specify an explicit directory: - string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length), appendPathSeparator: true); - archive.CreateEntry(entryName); - } - break; - case CreateEntryType.Unsupported: - default: - throw new IOException(SR.Format(SR.ZipUnsupportedFile, fullPath)); - } - } - - FinalizeCreateZipArchiveFromDirectory(archive, di, includeBaseDirectory, directoryIsEmpty); - } - - private static void CreateZipArchiveFromDirectory(string sourceDirectoryName, ZipArchive archive, - CompressionLevel compressionLevel, bool includeBaseDirectory, - ReadOnlySpan password, ZipEncryptionMethod encryptionMethod) - { - (bool directoryIsEmpty, string basePath, DirectoryInfo di, FileSystemEnumerable<(string, CreateEntryType)> fse) = - InitializeCreateZipArchiveFromDirectory(sourceDirectoryName, includeBaseDirectory); - - bool hasEncryption = !password.IsEmpty && encryptionMethod != ZipEncryptionMethod.None; - - foreach ((string fullPath, CreateEntryType type) in fse) - { - directoryIsEmpty = false; - - switch (type) - { - case CreateEntryType.File: - { - string entryName = ArchivingUtils.EntryFromPath(fullPath.AsSpan(basePath.Length)); - if (hasEncryption) - ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel, password, encryptionMethod); - else - ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel); + ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel, password, encryptionMethod); } break; case CreateEntryType.Directory: diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index fc86f429cc13bb..4c09066df95da9 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -273,10 +273,7 @@ private static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, foreach (ZipArchiveEntry entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); - if (!password.IsEmpty) - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); - else - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); } } } @@ -494,10 +491,7 @@ private static async Task ExtractToDirectoryAsync(Stream source, string destinat foreach (ZipArchiveEntry entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); - if (!password.IsEmpty) - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); - else - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs index cc258f91cd6d9a..ce890f32dbc4e3 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs @@ -44,7 +44,7 @@ public static partial class ZipFileExtensions /// The cancellation token to monitor for cancellation requests. /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, CancellationToken cancellationToken = default) => - DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, null, cancellationToken); + DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, null, cancellationToken: cancellationToken); /// ///

Asynchronously adds a file from the file system to the archive under the specified entry name using encryption.

@@ -107,7 +107,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des /// A task that represents the asynchronous operation. The value of the task is the newly created entry. public static Task CreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel, CancellationToken cancellationToken = default) => - DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, compressionLevel, cancellationToken); + DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, compressionLevel, cancellationToken: cancellationToken); /// ///

Asynchronously adds a file from the file system to the archive under the specified entry name using the specified compression level and encryption.

@@ -140,26 +140,7 @@ public static Task CreateEntryFromFileAsync(this ZipArchive des DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, compressionLevel, password, encryption, cancellationToken); internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, - CompressionLevel? compressionLevel, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); - - await using (fs) - { - Stream es = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using (es) - { - await fs.CopyToAsync(es, cancellationToken).ConfigureAwait(false); - } - } - - return entry; - } - - internal static async Task DoCreateEntryFromFileAsync(this ZipArchive destination, string sourceFileName, string entryName, - CompressionLevel? compressionLevel, ReadOnlyMemory password, ZipEncryptionMethod encryption, CancellationToken cancellationToken) + CompressionLevel? compressionLevel, ReadOnlyMemory password = default, ZipEncryptionMethod encryption = ZipEncryptionMethod.None, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index eabdff52adc6ac..89f998cff8812c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -127,25 +127,9 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, ReadOnlySpan password, ZipEncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); - internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel) - { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); - - using (fs) - { - using (Stream es = entry.Open()) - { - fs.CopyTo(es); - } - } - - return entry; - } - internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, - ReadOnlySpan password, ZipEncryptionMethod encryption) + ReadOnlySpan password = default, ZipEncryptionMethod encryption = ZipEncryptionMethod.None) { (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false, password, encryption); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index fc0e81cc2351a8..12a1788559f3d4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -79,7 +79,7 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string foreach (ZipArchiveEntry entry in source.Entries) { - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index d3af522eb58da8..45b6f77e983ec3 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -176,20 +176,7 @@ private static async Task ExtractToFileAsync(ZipArchiveEntry source, string dest } } - internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) - { - // If it is a file: - // Create containing directory: - Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, cancellationToken).ConfigureAwait(false); - } - } - - internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, ReadOnlyMemory password, CancellationToken cancellationToken = default) + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, ReadOnlyMemory password = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index 6992d195e8f64b..c92d4a98f947aa 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -388,7 +388,7 @@ A password is required for encrypted entries. - A non empty password is required for creating an encrypted entry. + A non-empty password is required for creating an encrypted entry. Invalid AES strength value. @@ -406,15 +406,15 @@ Encryption type is not specified. - Encryption method should not be specified in read Mode. + Encryption method should not be specified in read mode. - Encryption method should not be specified in update Mode. + Encryption method should not be specified in update mode. Zip archive encryption is not supported on browser platform. - + The entry's encryption method is not supported. - + \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 8189f6ae56f5b8..479cba91811a6d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -90,7 +90,7 @@ internal static WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); byte[] rentedPasswordBytes = System.Buffers.ArrayPool.Shared.Rent(maxPasswordByteCount); - // totalKeySize is at most 66 bytes (AES-256: 32 + 32 + 2), safe for stackalloc + Debug.Assert(totalKeySize <= 66, "totalKeySize should be at most 66 bytes (AES-256: 32 + 32 + 2)"); Span derivedKey = stackalloc byte[totalKeySize]; try @@ -102,7 +102,7 @@ internal static WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? passwordSpan, saltBytes, derivedKey, - 1000, + 1000, // iteration count specified by the WinZip AE-1/AE-2 specification HashAlgorithmName.SHA1); // Slice the derived key directly into its components instead of diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 211727700fb417..670984f78f7792 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -26,7 +26,9 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _passwordVerifier; private bool _headerWritten; private bool _disposed; - private bool _authCodeValidated; + // During decryption: set to true after the stored auth code is read and verified. + // During encryption: set to true after the computed auth code has been written. + private bool _authCodeFinalized; private readonly long _totalStreamSize; private readonly bool _leaveOpen; private readonly long _encryptedDataSize; @@ -130,6 +132,9 @@ private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStr private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen) { _baseStream = baseStream; + + Debug.Assert((totalStreamSize >= 0) == !encrypting, "Total stream size must be known when decrypting"); + _encrypting = encrypting; _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; @@ -164,30 +169,12 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon _aes.SetKey(keyMaterial.EncryptionKey); } - private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) - { - Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); - Debug.Assert(_hmac is not null, "HMAC should have been initialized"); - - if (_authCodeValidated) - { - return; - } - - // Read the 10-byte stored authentication code from the stream - byte[] storedAuth = new byte[10]; + private void FinalizeAndCompareHMAC(byte[] storedAuth) { - if (isAsync) - { - await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.ReadExactly(storedAuth); - } + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); // Finalize HMAC computation after reading, so we can use stackalloc - Span expectedAuth = stackalloc byte[20]; // SHA1 hash size + Span expectedAuth = stackalloc byte[SHA1.HashSizeInBytes]; if (!_hmac.TryGetHashAndReset(expectedAuth, out int bytesWritten) || bytesWritten < 10) { throw new InvalidDataException(SR.WinZipAuthCodeMismatch); @@ -198,48 +185,63 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can { throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } - - _authCodeValidated = true; } + private void ValidateAuthCode() { - ValidateAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + + if (_authCodeFinalized) + { + return; + } + + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + _baseStream.ReadExactly(storedAuth); + FinalizeAndCompareHMAC(storedAuth); + _authCodeFinalized = true; } - private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) { - return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); + Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + + if (_authCodeFinalized) + { + return; + } + + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + FinalizeAndCompareHMAC(storedAuth); + _authCodeFinalized = true; } - private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancellationToken) + private async Task WriteHeaderAsync(CancellationToken cancellationToken) { if (_headerWritten) { return; } - if (isAsync) - { - await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); - await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.Write(_salt); - _baseStream.Write(_passwordVerifier); - } + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); _headerWritten = true; } private void WriteHeader() { - WriteHeaderCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - } + if (_headerWritten) + { + return; + } - private Task WriteHeaderAsync(CancellationToken cancellationToken) - { - return WriteHeaderCoreAsync(isAsync: true, cancellationToken); + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + _headerWritten = true; } private void ProcessBlock(Span buffer) @@ -312,7 +314,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); Debug.Assert(_hmac is not null, "HMAC should have been initialized"); - if (_authCodeValidated) + if (_authCodeFinalized) { return; } @@ -336,7 +338,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel _baseStream.Write(authCode.Slice(0, 10)); } - _authCodeValidated = true; + _authCodeFinalized = true; } private void ThrowIfNotReadable() @@ -475,11 +477,9 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) } } - // Process full blocks - while (inputCount >= BlockSize) + while (inputCount > 0) { int bytesToProcess = Math.Min(inputCount, workBuffer.Length); - bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); @@ -622,7 +622,7 @@ protected override void Dispose(bool disposing) { try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeFinalized) { // Ensure header is written even for empty files if (!_headerWritten) @@ -666,7 +666,7 @@ public override async ValueTask DisposeAsync() try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeFinalized) { // Ensure header is written even for empty files if (!_headerWritten) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 6e8a431eb317a6..5817483d208712 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -19,22 +19,11 @@ public partial class ZipArchiveEntry /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public async Task OpenAsync(CancellationToken cancellationToken = default) + public Task OpenAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); - case ZipArchiveMode.Create: - return OpenInWriteMode(); - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); - } + return OpenAsyncCore(InferAccessFromMode(), default, cancellationToken); } /// @@ -56,41 +45,12 @@ public async Task OpenAsync(CancellationToken cancellationToken = defaul /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public async Task OpenAsync(FileAccess access, CancellationToken cancellationToken = default) + public Task OpenAsync(FileAccess access, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - - if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) - throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); - - // Validate that the requested access is compatible with the archive's mode - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - if (access != FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); - - case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return OpenInWriteMode(); - - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) - { - case FileAccess.Read: - return await OpenInReadModeAsync(checkOpenable: true, cancellationToken).ConfigureAwait(false); - case FileAccess.Write: - return await OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken).ConfigureAwait(false); - case FileAccess.ReadWrite: - default: - return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken).ConfigureAwait(false); - } - } + ValidateAccessForMode(access); + return OpenAsyncCore(access, default, cancellationToken); } /// @@ -116,49 +76,12 @@ public Task OpenAsync(FileAccess access, ReadOnlySpan password, Ca { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); - - if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) - throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + ValidateAccessForMode(access); if (IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); - bool usePassword = IsEncrypted && !password.IsEmpty; - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - if (access != FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - return usePassword - ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) - : OpenInReadModeAsync(checkOpenable: true, cancellationToken); - - case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return Task.FromResult(OpenInWriteMode()); - - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) - { - case FileAccess.Read: - return usePassword - ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) - : OpenInReadModeAsync(checkOpenable: true, cancellationToken); - case FileAccess.Write: - return usePassword - ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: false, password, cancellationToken) - : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken)); - case FileAccess.ReadWrite: - default: - return usePassword - ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: true, password, cancellationToken) - : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken)); - } - } + return OpenAsyncCore(access, password, cancellationToken); } /// @@ -183,22 +106,32 @@ public Task OpenAsync(ReadOnlySpan password, CancellationToken can if (IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); + return OpenAsyncCore(InferAccessFromMode(), password, cancellationToken); + } + + private Task OpenAsyncCore(FileAccess access, ReadOnlySpan password, CancellationToken cancellationToken) + { bool usePassword = IsEncrypted && !password.IsEmpty; switch (_archive.Mode) { case ZipArchiveMode.Read: - return usePassword - ? OpenInReadModeWithPasswordAsync(checkOpenable: true, password, cancellationToken) - : OpenInReadModeAsync(checkOpenable: true, cancellationToken); + return OpenInReadModeAsync(checkOpenable: true, password, cancellationToken); case ZipArchiveMode.Create: return Task.FromResult(OpenInWriteMode()); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return usePassword - ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: true, password, cancellationToken) - : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken)); + return access switch + { + FileAccess.Read => OpenInReadModeAsync(checkOpenable: true, password, cancellationToken), + FileAccess.Write => usePassword + ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: false, password, cancellationToken) + : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: false, cancellationToken)), + _ => usePassword + ? OpenInUpdateModeWithPasswordAsync(loadExistingContent: true, password, cancellationToken) + : CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken)), + }; } } @@ -256,16 +189,86 @@ internal async Task ReadEncryptionSaltIfNeededAsync(CancellationToken cancellati } } - private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) + private Task OpenInReadModeAsync(bool checkOpenable, ReadOnlySpan password, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (checkOpenable) - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); - long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + // Derive key material from the password span before entering the async core. + WinZipAesKeyMaterial? aesKeys = null; + ZipCryptoKeys? zipCryptoKeys = null; + byte zipCryptoCheckByte = 0; + + if (IsEncrypted) + { + if (Encryption == ZipEncryptionMethod.Unknown) + { + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + if (password.IsEmpty) + { + throw new InvalidDataException(SR.PasswordRequired); + } + + if (IsAesEncrypted) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + if (_aesSalt is null) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } + + int keySizeBits = GetAesKeySizeBits(Encryption); + aesKeys = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); + } + else if (IsZipCryptoEncrypted) + { + zipCryptoCheckByte = CalculateZipCryptoCheckByte(); + zipCryptoKeys = ZipCryptoStream.CreateKey(password); + } + else + { + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + } - return BuildDecompressionPipeline(compressedStream); + return OpenInReadModeAsyncCore(checkOpenable, aesKeys, zipCryptoKeys, zipCryptoCheckByte, cancellationToken); + + async Task OpenInReadModeAsyncCore(bool checkOpenable, WinZipAesKeyMaterial? aesKeys, ZipCryptoKeys? zipCryptoKeys, byte zipCryptoCheckByte, CancellationToken cancellationToken) + { + if (checkOpenable) + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + + long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); + Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); + + Stream streamToDecompress; + if (aesKeys is not null) + { + streamToDecompress = await WinZipAesStream.CreateAsync( + baseStream: compressedStream, + keyMaterial: aesKeys.Value, + totalStreamSize: _compressedSize, + encrypting: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + else if (zipCryptoKeys is not null) + { + streamToDecompress = await ZipCryptoStream.CreateAsync( + compressedStream, zipCryptoKeys.Value, zipCryptoCheckByte, + encrypting: false, cancellationToken).ConfigureAwait(false); + } + else + { + streamToDecompress = compressedStream; + } + + return BuildDecompressionPipeline(streamToDecompress); + } } private async Task OpenInUpdateModeAsync(bool loadExistingContent, CancellationToken cancellationToken) @@ -314,7 +317,7 @@ private async Task GetUncompressedDataAsync(CancellationToken canc if (_originallyInArchive) { - Stream decompressor = await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); + Stream decompressor = await OpenInReadModeAsync(checkOpenable: false, default, cancellationToken).ConfigureAwait(false); await using (decompressor) { @@ -445,89 +448,6 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo throw new InvalidDataException(message); } - /// - /// Accepts a password, derives decryption keys (CPU-only), - /// and delegates all I/O to fully async helper methods. - /// - private Task OpenInReadModeWithPasswordAsync(bool checkOpenable, ReadOnlySpan password, CancellationToken cancellationToken) - { - if (Encryption == ZipEncryptionMethod.Unknown) - { - throw new NotSupportedException(SR.UnsupportedEncryptionMethod); - } - - if (password.IsEmpty) - { - throw new InvalidDataException(SR.PasswordRequired); - } - - if (IsAesEncrypted) - { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } - - if (_aesSalt is null) - { - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - } - - int keySizeBits = GetAesKeySizeBits(Encryption); - WinZipAesKeyMaterial aesKeys = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); - return OpenWithAesDecryptionAsync(checkOpenable, aesKeys, cancellationToken); - } - - if (IsZipCryptoEncrypted) - { - byte checkByte = CalculateZipCryptoCheckByte(); - ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); - return OpenWithZipCryptoDecryptionAsync(checkOpenable, keys, checkByte, cancellationToken); - } - - throw new NotSupportedException(SR.UnsupportedEncryptionMethod); - } - - private async Task OpenWithZipCryptoDecryptionAsync(bool checkOpenable, ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) - { - if (checkOpenable) - { - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); - } - - long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); - - Stream decrypted = await ZipCryptoStream.CreateAsync(compressedStream, keys, checkByte, encrypting: false, cancellationToken).ConfigureAwait(false); - - return BuildDecompressionPipeline(decrypted); - } - - private async Task OpenWithAesDecryptionAsync(bool checkOpenable, WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) - { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } - - if (checkOpenable) - { - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); - } - - long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); - Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); - - Stream decrypted = await WinZipAesStream.CreateAsync( - baseStream: compressedStream, - keyMaterial: aesKeys, - totalStreamSize: _compressedSize, - encrypting: false, - cancellationToken: cancellationToken).ConfigureAwait(false); - - return BuildDecompressionPipeline(decrypted); - } - /// /// Accepts a password, derives decryption and re-encryption keys (CPU-only), /// and delegates all I/O to fully async helper methods. 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 460bdaa7488aaf..930c0f4425ea7e 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 @@ -333,6 +333,11 @@ private set } } + /// + /// Returns the relative path of the entry in the Zip archive, equivalent to . + /// + public override string ToString() => FullName; + /// /// The last write time of the entry as stored in the Zip archive. When setting this property, the DateTime will be converted to the /// Zip timestamp format, which supports a resolution of two seconds. If the data in the last write time field is not a valid Zip timestamp, @@ -423,18 +428,7 @@ public void Delete() public Stream Open() { ThrowIfInvalidArchive(); - - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - return OpenInReadMode(checkOpenable: true); - case ZipArchiveMode.Create: - return OpenInWriteMode(); - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return OpenInUpdateMode(); - } + return OpenCore(InferAccessFromMode()); } @@ -455,21 +449,7 @@ public Stream Open(ReadOnlySpan password) if (IsEncrypted && password.IsEmpty) throw new ArgumentException(SR.PasswordRequired, nameof(password)); - switch (_archive.Mode) - { - case ZipArchiveMode.Read: - return IsEncrypted && !password.IsEmpty - ? OpenInReadMode(checkOpenable: true, password) - : OpenInReadMode(checkOpenable: true); - case ZipArchiveMode.Create: - return OpenInWriteMode(); - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return IsEncrypted && !password.IsEmpty - ? OpenInUpdateMode(loadExistingContent: true, password) - : OpenInUpdateMode(); - } + return OpenCore(InferAccessFromMode(), password); } /// @@ -493,96 +473,66 @@ public Stream Open(ReadOnlySpan password) public Stream Open(FileAccess access) { ThrowIfInvalidArchive(); + ValidateAccessForMode(access); + return OpenCore(access); + } + + public Stream Open(FileAccess access, ReadOnlySpan password) + { + ThrowIfInvalidArchive(); + ValidateAccessForMode(access); + + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + + return OpenCore(access, password); + } + + private FileAccess InferAccessFromMode()=> _archive.Mode switch + { + ZipArchiveMode.Read => FileAccess.Read, + ZipArchiveMode.Create => FileAccess.Write, + _ => FileAccess.ReadWrite + }; + private void ValidateAccessForMode(FileAccess access) + { if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); - // Validate that the requested access is compatible with the archive's mode switch (_archive.Mode) { case ZipArchiveMode.Read: if (access != FileAccess.Read) throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - return OpenInReadMode(checkOpenable: true); - + break; case ZipArchiveMode.Create: if (access == FileAccess.Read) throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return OpenInWriteMode(); - - case ZipArchiveMode.Update: - default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) - { - case FileAccess.Read: - return OpenInReadMode(checkOpenable: true); - case FileAccess.Write: - return OpenInUpdateMode(loadExistingContent: false); - case FileAccess.ReadWrite: - default: - return OpenInUpdateMode(loadExistingContent: true); - } + break; } } - public Stream Open(FileAccess access, ReadOnlySpan password) + private Stream OpenCore(FileAccess access, ReadOnlySpan password = default) { - ThrowIfInvalidArchive(); - - if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) - throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); - - if (IsEncrypted && password.IsEmpty) - throw new ArgumentException(SR.PasswordRequired, nameof(password)); - - bool usePassword = IsEncrypted && !password.IsEmpty; - switch (_archive.Mode) { case ZipArchiveMode.Read: - if (access != FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); - return usePassword - ? OpenInReadMode(checkOpenable: true, password) - : OpenInReadMode(checkOpenable: true); - + return OpenInReadMode(checkOpenable: true, password); case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); return OpenInWriteMode(); - case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) + return access switch { - case FileAccess.Read: - return usePassword - ? OpenInReadMode(checkOpenable: true, password) - : OpenInReadMode(checkOpenable: true); - case FileAccess.Write: - return usePassword - ? OpenInUpdateMode(loadExistingContent: false, password) - : OpenInUpdateMode(loadExistingContent: false); - case FileAccess.ReadWrite: - default: - return usePassword - ? OpenInUpdateMode(loadExistingContent: true, password) - : OpenInUpdateMode(loadExistingContent: true); - } + FileAccess.Read => OpenInReadMode(checkOpenable: true, password), + FileAccess.Write => OpenInUpdateMode(loadExistingContent: false, password), + _ => OpenInUpdateMode(loadExistingContent: true, password), + }; } } - /// - /// Returns the FullName of the entry. - /// - /// FullName of the entry - public override string ToString() - { - return FullName; - } - private string DecodeEntryString(byte[] entryStringBytes) { Debug.Assert(entryStringBytes != null); diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index bb9fd684a4d08c..202a96d8d3f40e 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -94,7 +94,7 @@ static WinZipAesStreamConformanceTests() var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - ms, keyMaterial, 0L, true, false + ms, keyMaterial, -1L, true, false })!; if (initialData != null && initialData.Length > 0) @@ -116,7 +116,7 @@ static WinZipAesStreamConformanceTests() using var encryptedMs = new MemoryStream(); using (var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - encryptedMs, encryptKeyMaterial, 0L, true, true + encryptedMs, encryptKeyMaterial, -1L, true, true })!) { encryptStream.Write(plaintext); From b09f09cf98bbc6006138c33933571eb414247f91 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 24 Apr 2026 15:24:48 +0200 Subject: [PATCH 67/83] remove local tests and fix fuzzing bug --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 5 + .../Fuzzers/ZipCryptoStreamFuzzer.cs | 5 + .../tests/ZipFile.Extract.cs | 1870 ----------------- 3 files changed, 10 insertions(+), 1870 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 92ee68b2c2b216..c4ef13c77f2a1f 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -155,6 +155,11 @@ private async Task TestStream(byte[] buffer, int length, bool async) { // ignore, crypto failures are expected for random fuzz input. } + catch (TargetInvocationException ex) when (ex.InnerException is InvalidDataException or CryptographicException) + { + // The reflected WinZipAesStream.Create call wraps exceptions + // in TargetInvocationException when header validation fails. + } finally { ArrayPool.Shared.Return(buffer); diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index d427b0972f0cf7..9a215d8384e599 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -118,6 +118,11 @@ private async Task TestStream(byte[] buffer, int length, bool async) { // ignore, this exception is expected for invalid/corrupted data. } + catch (TargetInvocationException ex) when (ex.InnerException is InvalidDataException) + { + // The reflected ZipCryptoStream.Create call wraps InvalidDataException + // (e.g. password mismatch, truncated header) in TargetInvocationException. + } finally { ArrayPool.Shared.Return(buffer); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 9ac5027db13986..516835a86b3579 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; -using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -11,10 +10,6 @@ namespace System.IO.Compression.Tests { public class ZipFile_Extract : ZipFileTestBase { - - private const string DownloadsDir = @"C:\Users\spahontu\Downloads"; - private static string NewPath(string file) => Path.Combine(DownloadsDir, file); - public static IEnumerable Get_ExtractToDirectoryNormal_Data() { foreach (bool async in _bools) @@ -225,1870 +220,5 @@ public async Task DirectoryEntryWithData(bool async) await DisposeZipArchive(async, archive); await Assert.ThrowsAsync(() => CallZipFileExtractToDirectory(async, archivePath, GetTestFilePath())); } - - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenEncryptedTxtFile_ShouldReturnPlaintext() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - using var archive = ZipFile.OpenRead(zipPath); - - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); - using var stream = entry.Open("123456789"); - using var reader = new StreamReader(stream); - string content = reader.ReadToEnd(); - - Assert.Equal("Hello ZipCrypto!", content); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() - { - - string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; - string EntryName = "hello.txt"; - string CorrectPassword = "123456789"; - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(ZipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); - - // Act: Extract using password - entry.ExtractToFile(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = CorrectPassword.AsMemory() }); - - // Assert: File exists and content matches expected plaintext - Assert.True(File.Exists(tempFile), "Extracted file was not created."); - string content = File.ReadAllText(tempFile); - Assert.Equal("Hello ZipCrypto!", content); - - // Cleanup - File.Delete(tempFile); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() - { - string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; - string EntryName = "hello.txt"; - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(ZipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); - - Assert.Throws(() => - { - entry.ExtractToFile(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "wrongpass".AsMemory() }); - }); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() - { - string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; - string EntryName = "hello.txt"; - ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(ZipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); - - Assert.Throws(() => - { - entry.ExtractToFile(tempFile, overwrite: true); // No password passed - }); - } - - [Fact] - [SkipOnCI("Local development test - requires specific file paths")] - public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - - await entry.ExtractToFileAsync(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "123456789".AsMemory() }); - - Assert.True(File.Exists(tempFile), "Extracted file was not created."); - string content = await File.ReadAllTextAsync(tempFile); - Assert.Equal("Hello ZipCrypto!", content); - - File.Delete(tempFile); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - - await Assert.ThrowsAsync(async () => - { - await entry.ExtractToFileAsync(tempFile, new ZipExtractionOptions { OverwriteFiles = true, Password = "wrongpass".AsMemory() }); - }); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ExtractToFileAsync_WithoutPassword_ShouldThrow() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - - await Assert.ThrowsAsync(async () => - { - await entry.ExtractToFileAsync(tempFile, overwrite: true, cancellationToken: default); // No password passed - }); - } - - [Fact] - [SkipOnCI("Local development test - requires specific file paths")] - public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() - { - // Arrange - string zipPath = @"C:\Users\spahontu\Downloads\combined.zip"; - string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; - Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); - Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); - - // Open archive with password - using var archive = ZipFile.OpenRead(zipPath); - - // 1) Validate hello.txt - var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - using (var txtStream = txtEntry.Open("123456789")) - using (var reader = new StreamReader(txtStream)) - { - string content = reader.ReadToEnd(); - Assert.Equal("Hello ZipCrypto!", content); - } - - // 2) Validate test.jpg - var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open("123456789")) - using (var ms = new MemoryStream()) - { - jpgStream.CopyTo(ms); - byte[] actualBytes = ms.ToArray(); - - // Quick sanity: JPEG SOI marker - Assert.True(actualBytes.Length > 3, "JPEG too small"); - Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); - - // Full comparison with original file - byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); - Assert.Equal(expectedBytes.Length, actualBytes.Length); - Assert.Equal(expectedBytes, actualBytes); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDecryptBoth() - { - // Arrange - string zipPath = @"C:\Users\spahontu\Downloads\combinedpass.zip"; - string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; - Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); - Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); - - // Open archive with password - using var archive = ZipFile.OpenRead(zipPath); - - // 1) Validate hello.txt - var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - using (var txtStream = txtEntry.Open()) - using (var reader = new StreamReader(txtStream)) - { - string content = reader.ReadToEnd(); - Assert.Equal("Hello ZipCrypto!", content); - } - - // 2) Validate test.jpg - var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open("123456789")) - using (var ms = new MemoryStream()) - { - jpgStream.CopyTo(ms); - byte[] actualBytes = ms.ToArray(); - - // Quick sanity: JPEG SOI marker - Assert.True(actualBytes.Length > 3, "JPEG too small"); - Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); - - // Full comparison with original file - byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); - Assert.Equal(expectedBytes.Length, actualBytes.Length); - Assert.Equal(expectedBytes, actualBytes); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() - { - // Arrange - const string downloadsDir = @"C:\Users\spahontu\Downloads"; - const string zipPath = $@"{downloadsDir}\zipcrypto_test.zip"; - const string entryName = "hello.txt"; - const string password = "P@ssw0rd!"; - const string expectedContent = "hello zipcrypto"; - - // Ensure target directory exists - Directory.CreateDirectory(downloadsDir); - - // Clean up any previous file - if (File.Exists(zipPath)) - File.Delete(zipPath); - - // ACT 1: Create the archive and write one encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - // Your custom overload that sets per-entry password & ZipCrypto - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - - using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - writer.Write(expectedContent); - } - - // ACT 2: Open the archive for reading and read back the content using the password - string actualContent; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - // Your custom entry decryption API: Open(password) - using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [Fact] - public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_multi_samepw.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - var items = new (string Name, string Content)[] - { - ("a.txt", "alpha"), - ("b/config.json", "{\"k\":1}"), - ("c/readme.md", "# readme"), - }; - const string password = "S@m3PW!"; - const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; - - // Act 1: Create with same password for all - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - foreach (var it in items) - { - var entry = za.CreateEntry(it.Name, password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); - } - } - - // Act 2: Read back using same password for each entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - foreach (var it in items) - { - var e = za.GetEntry(it.Name); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - string content = await r.ReadToEndAsync(); - Assert.Equal(it.Content, content); - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_multi_diffpw.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - var items = new (string Name, string Content, string Password)[] - { - ("d.txt", "delta", "pw-d"), - ("e/info.txt", "echo-info", "pw-e"), - ("f/sub/notes.txt", "foxtrot-notes", "pw-f"), - }; - const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; - - // Act 1: Create, each entry with its own password - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - foreach (var it in items) - { - var entry = za.CreateEntry(it.Name, it.Password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); - } - } - - // Act 2: Read back with matching password per entry, and also verify a wrong password fails - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - foreach (var it in items) - { - var e = za.GetEntry(it.Name); - Assert.NotNull(e); - - // Correct password - using (var r = new StreamReader(e!.Open(it.Password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - string content = await r.ReadToEndAsync(); - Assert.Equal(it.Content, content); - } - - // Wrong password should throw (ZipCrypto header check fails) - Assert.ThrowsAny(() => - { - using var _ = e.Open("WRONG-PASSWORD"); - }); - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_mixed.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string encPw = "EncOnly123!"; - const ZipEncryptionMethod enc = ZipEncryptionMethod.ZipCrypto; - - var encryptedItems = new (string Name, string Content)[] - { - ("secure/one.txt", "top-secret-1"), - ("secure/two.txt", "top-secret-2"), - }; - - var plainItems = new (string Name, string Content)[] - { - ("public/a.txt", "visible-a"), - ("public/b.txt", "visible-b"), - }; - - // Act 1: Create archive with both encrypted and plain entries - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - // Encrypted - foreach (var it in encryptedItems) - { - var entry = za.CreateEntry(it.Name, encPw, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - w.Write(it.Content); - } - - // Plain - foreach (var it in plainItems) - { - var entry = za.CreateEntry(it.Name); // default: no encryption - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - w.Write(it.Content); - } - } - - // Act 2: Read back—encrypted need password, plain do not - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - // Encrypted - foreach (var it in encryptedItems) - { - var e = za.GetEntry(it.Name); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(encPw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - string content = await r.ReadToEndAsync(); - Assert.Equal(it.Content, content); - } - - // Plain - foreach (var it in plainItems) - { - var e = za.GetEntry(it.Name); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - string content = await r.ReadToEndAsync(); - Assert.Equal(it.Content, content); - - // Opening a plain entry with a password should succeed — password is ignored - using (var s = e.Open("some-password")) - using (var r2 = new StreamReader(s, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - string content2 = await r2.ReadToEndAsync(); - Assert.Equal(it.Content, content2); - } - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_async_test.zip"); - const string entryName = "async_test.txt"; - const string password = "AsyncP@ss123"; - const string expectedContent = "This is async ZipCrypto content"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Create archive with async write - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var stream = entry.Open(); - - byte[] data = Encoding.UTF8.GetBytes(expectedContent); - await stream.WriteAsync(data, 0, data.Length); - await stream.FlushAsync(); - } - - // Act 2: Read back with async read - string actualContent; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var stream = entry!.Open(password); - using var reader = new StreamReader(stream, Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_multi_write.zip"); - const string entryName = "multi_write.txt"; - const string password = "MultiWrite123"; - - var parts = new[] { "Part1-", "Part2-", "Part3" }; - string expectedContent = string.Concat(parts); - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Create with multiple async writes - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var stream = entry.Open(); - - foreach (var part in parts) - { - byte[] data = Encoding.UTF8.GetBytes(part); - await stream.WriteAsync(data, 0, data.Length); - } - await stream.FlushAsync(); - } - - // Act 2: Read back - string actualContent; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_chunked_read.zip"); - const string entryName = "chunked.txt"; - const string password = "ChunkedRead!"; - - // Create larger content - string expectedContent = string.Concat(Enumerable.Repeat("0123456789ABCDEF", 100)); - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Create entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); - await writer.WriteAsync(expectedContent); - } - - // Act 2: Read in chunks asynchronously using StreamReader to handle BOM properly - string actualContent; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var stream = entry!.Open(password); - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_mixed_ops.zip"); - const string syncEntryName = "sync.txt"; - const string asyncEntryName = "async.txt"; - const string password = "MixedOps123"; - const string syncContent = "Synchronous write content"; - const string asyncContent = "Asynchronous write content"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Create with mixed sync/async writes - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - // Synchronous write - var syncEntry = za.CreateEntry(syncEntryName, password, ZipEncryptionMethod.ZipCrypto); - using (var syncWriter = new StreamWriter(syncEntry.Open(), Encoding.UTF8)) - { - syncWriter.Write(syncContent); - } - - // Asynchronous write - var asyncEntry = za.CreateEntry(asyncEntryName, password, ZipEncryptionMethod.ZipCrypto); - using (var asyncWriter = new StreamWriter(asyncEntry.Open(), Encoding.UTF8)) - { - await asyncWriter.WriteAsync(asyncContent); - } - } - - // Act 2: Read with mixed sync/async reads - string actualSyncContent, actualAsyncContent; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - // Async read of sync-written entry - var syncEntry = za.GetEntry(syncEntryName); - Assert.NotNull(syncEntry); - using (var reader1 = new StreamReader(syncEntry!.Open(password), Encoding.UTF8)) - { - actualSyncContent = await reader1.ReadToEndAsync(); - } - - // Sync read of async-written entry - var asyncEntry = za.GetEntry(asyncEntryName); - Assert.NotNull(asyncEntry); - using (var reader2 = new StreamReader(asyncEntry!.Open(password), Encoding.UTF8)) - { - actualAsyncContent = reader2.ReadToEnd(); - } - } - - // Assert - Assert.Equal(syncContent, actualSyncContent); - Assert.Equal(asyncContent, actualAsyncContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_large_async.zip"); - const string entryName = "large.bin"; - const string password = "LargeFile123"; - - // Create 1MB of random data - var random = new Random(42); - var expectedData = new byte[1024 * 1024]; - random.NextBytes(expectedData); - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Write large data asynchronously - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var stream = entry.Open(); - - // Write in 64KB chunks - const int chunkSize = 65536; - for (int offset = 0; offset < expectedData.Length; offset += chunkSize) - { - int count = Math.Min(chunkSize, expectedData.Length - offset); - await stream.WriteAsync(expectedData, offset, count); - } - await stream.FlushAsync(); - } - - // Act 2: Read large data asynchronously - byte[] actualData; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var stream = entry!.Open(password); - using var ms = new MemoryStream(); - await stream.CopyToAsync(ms); - actualData = ms.ToArray(); - } - - // Assert - Assert.Equal(expectedData.Length, actualData.Length); - Assert.Equal(expectedData, actualData); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_copyto.zip"); - const string entryName = "copyto.dat"; - const string password = "CopyTo123!"; - - var expectedData = new byte[32768]; - new Random(123).NextBytes(expectedData); - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Act 1: Write using CopyToAsync - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName, password, ZipEncryptionMethod.ZipCrypto); - using var entryStream = entry.Open(); - using var sourceStream = new MemoryStream(expectedData); - - await sourceStream.CopyToAsync(entryStream); - } - - // Act 2: Read using CopyToAsync - byte[] actualData; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var destStream = new MemoryStream(); - - await entryStream.CopyToAsync(destStream); - actualData = destStream.ToArray(); - } - - // Assert - Assert.Equal(expectedData, actualData); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() - { - string zipPath = Path.Join(DownloadsDir, "source_plain_winzip.zip"); - using var archive = ZipFile.OpenRead(zipPath); - - var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); - using var stream = entry.Open("123456789"); - using var reader = new StreamReader(stream); - string content = reader.ReadToEnd(); - - Assert.Equal("this is plain", content); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() - { - string zipPath = Path.Join(DownloadsDir, "source_plain.zip"); - using var archive = ZipFile.OpenRead(zipPath); - - var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); - using var stream = entry.Open("123456789"); - using var reader = new StreamReader(stream); - string content = reader.ReadToEnd(); - - Assert.Equal("this is plain", content); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() - { - string zipPath = Path.Join(DownloadsDir, "source_plain7z.zip"); - using var archive = ZipFile.OpenRead(zipPath); - - var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); - using var stream = entry.Open("123456789"); - using var reader = new StreamReader(stream); - string content = reader.ReadToEnd(); - - Assert.Equal("this is plain", content); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() - { - string zipPath = Path.Join(DownloadsDir, "multiple_entries_aes.zip"); - using var archive = ZipFile.OpenRead(zipPath); - - // Test first entry - var entry1 = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); - using (var stream1 = entry1.Open("123456789")) - using (var reader1 = new StreamReader(stream1)) - { - string content1 = reader1.ReadToEnd(); - Assert.Equal("this is plain", content1); - } - - // Test second entry - var entry2 = archive.Entries.First(e => e.FullName.EndsWith("source_plain_2.txt")); - using (var stream2 = entry2.Open("123456789")) - using (var reader2 = new StreamReader(stream2)) - { - string content2 = reader2.ReadToEnd(); - Assert.Equal("this is plain", content2); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() - { - // Arrange - string tempPath = Path.Join(DownloadsDir, "source_plain_mine.zip"); - const string entryName = "source_plain.txt"; - const string password = "123456789"; - const string expectedContent = "this is plain"; - - // Act 1: Create ZIP with AES-256 encrypted entry - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(expectedContent); - } - - // Act 2: Read back the encrypted entry - string actualContent; - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "multiple_aes256_entries.zip"); - const string password = "123456789"; - - var entries = new (string Name, string Content)[] - { - ("entry1.txt", "First encrypted entry"), - ("folder/entry2.txt", "Second encrypted entry in folder"), - ("folder/subfolder/entry3.md", "# Third Entry\nMarkdown content"), - ("data.json", "{\"key\": \"value\", \"encrypted\": true}"), - ("readme.txt", "This is AES-256 encrypted content") - }; - - // Act 1: Create ZIP with multiple AES-256 encrypted entries - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content) in entries) - { - var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Act 2: Read back all encrypted entries - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - string actualContent = await reader.ReadToEndAsync(); - - // Assert each entry's content matches - Assert.Equal(expectedContent, actualContent); - } - - // Verify wrong password fails - var firstEntry = archive.GetEntry(entries[0].Name); - Assert.NotNull(firstEntry); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "multiple_aes256_diff_passwords.zip"); - - var entries = new (string Name, string Content, string Password)[] - { - ("secure1.txt", "Content with password1", "password1"), - ("secure2.txt", "Content with password2", "password2"), - ("folder/secure3.txt", "Content with password3", "password3") - }; - - // Act 1: Create ZIP with AES-256 encrypted entries using different passwords - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content, pwd) in entries) - { - var entry = archive.CreateEntry(name, pwd, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Act 2: Read back entries with their respective passwords - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent, pwd) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - // Correct password should work - using (var entryStream = entry!.Open(pwd)) - using (var reader = new StreamReader(entryStream, Encoding.UTF8)) - { - string actualContent = await reader.ReadToEndAsync(); - Assert.Equal(expectedContent, actualContent); - } - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "mixed_plain_aes256.zip"); - const string password = "securePassword123"; - - var encryptedEntries = new (string Name, string Content)[] - { - ("secure/credentials.txt", "username=admin\npassword=secret"), - ("secure/data.json", "{\"sensitive\": true}") - }; - - var plainEntries = new (string Name, string Content)[] - { - ("readme.txt", "This archive contains both encrypted and plain files"), - ("public/info.txt", "This is public information") - }; - - // Act 1: Create ZIP with both plain and AES-256 encrypted entries - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - // Add encrypted entries - foreach (var (name, content) in encryptedEntries) - { - var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(content); - } - - // Add plain entries - foreach (var (name, content) in plainEntries) - { - var entry = archive.CreateEntry(name); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Act 2: Read back both encrypted and plain entries - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - // Read encrypted entries with password - foreach (var (name, expectedContent) in encryptedEntries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - string actualContent = await reader.ReadToEndAsync(); - Assert.Equal(expectedContent, actualContent); - } - - // Read plain entries without password - foreach (var (name, expectedContent) in plainEntries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - string actualContent = await reader.ReadToEndAsync(); - Assert.Equal(expectedContent, actualContent); - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes128_single.zip"); - const string entryName = "test_aes128.txt"; - const string password = "Test123!@#"; - const string expectedContent = "This content is encrypted with AES-128"; - - // Act 1: Create ZIP with AES-128 encrypted entry - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(expectedContent); - } - - // Act 2: Read back the encrypted entry - string actualContent; - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes192_single.zip"); - const string entryName = "test_aes192.txt"; - const string password = "SecurePass456$%^"; - const string expectedContent = "This content is protected with AES-192 encryption"; - - // Act 1: Create ZIP with AES-192 encrypted entry - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes192); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(expectedContent); - } - - // Act 2: Read back the encrypted entry - string actualContent; - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "mixed_aes_levels.zip"); - - var entries = new (string Name, string Content, string Password, ZipEncryptionMethod Encryption)[] - { - ("aes128/file1.txt", "AES-128 encrypted content", "password128", ZipEncryptionMethod.Aes128), - ("aes192/file2.txt", "AES-192 encrypted content", "password192", ZipEncryptionMethod.Aes192), - ("aes256/file3.txt", "AES-256 encrypted content", "password256", ZipEncryptionMethod.Aes256), - ("mixed/doc1.json", "{\"encryption\": \"AES-128\"}", "jsonPass128", ZipEncryptionMethod.Aes128), - ("mixed/doc2.xml", "AES-192", "xmlPass192", ZipEncryptionMethod.Aes192) - }; - - // Act 1: Create ZIP with entries using different AES encryption levels - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content, pwd, encryption) in entries) - { - var entry = archive.CreateEntry(name, pwd, encryption); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(content); - } - } - - // Act 2: Read back all encrypted entries with their respective passwords - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent, pwd, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - // Correct password should work - using (var entryStream = entry!.Open(pwd)) - using (var reader = new StreamReader(entryStream, Encoding.UTF8)) - { - string actualContent = reader.ReadToEnd(); - Assert.Equal(expectedContent, actualContent); - } - - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void CreateLargeFileWithAES128_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes128_large2.zip"); - const string entryName = "large_file.bin"; - const string password = "LargeFilePass123!"; - - // Create a larger content - var random = new Random(42); // Seed for reproducibility - var largeContent = new byte[1024 * 1024]; - random.NextBytes(largeContent); - - // Act 1: Create ZIP with AES-128 encrypted large entry - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); - using var entryStream = entry.Open(); - entryStream.Write(largeContent); - } - - // Act 2: Read back and verify the large encrypted entry - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var ms = new MemoryStream(); - entryStream.CopyTo(ms); - var actualContent = ms.ToArray(); - - // Assert - Assert.Equal(largeContent.Length, actualContent.Length); - Assert.Equal(largeContent, actualContent); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateCompressedAndAES192Encrypted_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes192_compressed.zip"); - const string password = "CompressedPass!"; - - // Create highly compressible content - string repeatedContent = string.Join("\n", Enumerable.Repeat("This line is repeated many times to test compression with AES-192.", 100)); - - var entries = new (string Name, string Content, CompressionLevel Level)[] - { - ("optimal.txt", repeatedContent, CompressionLevel.Optimal), - ("fastest.txt", repeatedContent, CompressionLevel.Fastest), - ("smallest.txt", repeatedContent, CompressionLevel.SmallestSize), - ("nocompression.txt", repeatedContent, CompressionLevel.NoCompression) - }; - - // Act 1: Create ZIP with AES-192 encrypted entries at different compression levels - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content, level) in entries) - { - var entry = archive.CreateEntry(name, level, password, ZipEncryptionMethod.Aes192); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(content); - } - } - - // Act 2: Read back all entries - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - string actualContent = await reader.ReadToEndAsync(); - - Assert.Equal(expectedContent, actualContent); - } - } - - // Verify file sizes are different due to compression levels - var fileInfo = new FileInfo(tempPath); - Assert.True(fileInfo.Exists); - Assert.True(fileInfo.Length > 0); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task MixAllEncryptionTypes_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "all_encryption_types.zip"); - - var entries = new (string Name, string Content, string? Password, ZipEncryptionMethod? Encryption)[] - { - // Plain entry - ("plain/readme.txt", "This is a plain unencrypted file", null, null), - - // ZipCrypto - ("zipcrypto/secret.txt", "ZipCrypto encrypted content", "zipPass", ZipEncryptionMethod.ZipCrypto), - - // AES-128 - ("aes128/data.txt", "AES-128 encrypted data", "aes128Pass", ZipEncryptionMethod.Aes128), - - // AES-192 - ("aes192/config.json", "{\"level\": \"AES-192\"}", "aes192Pass", ZipEncryptionMethod.Aes192), - - // AES-256 - ("aes256/secure.xml", "AES-256 secured", "aes256Pass", ZipEncryptionMethod.Aes256) - }; - - // Act 1: Create ZIP with all encryption types - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content, pwd, encryption) in entries) - { - var entry = pwd != null && encryption.HasValue - ? archive.CreateEntry(name, pwd, encryption.Value) - : archive.CreateEntry(name); - Stream entryStream = entry.Open(); - - using (entryStream) - using (var writer = new StreamWriter(entryStream, Encoding.UTF8)) - { - await writer.WriteAsync(content); - } - } - } - - // Act 2: Read back all entries - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent, pwd, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - Stream entryStream = pwd != null - ? entry!.Open(pwd) - : entry!.Open(); - - using (entryStream) - using (var reader = new StreamReader(entryStream, Encoding.UTF8)) - { - string actualContent = await reader.ReadToEndAsync(); - Assert.Equal(expectedContent, actualContent); - } - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() - { - // Arrange - string zipPath = Path.Join(DownloadsDir, "source_plain_ae1.zip"); - const string entryName = "source_plain.txt"; - const string password = "123456789"; - const string expectedContent = "this is plain"; - - // Act - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith(entryName)); - - using var stream = entry.Open(password); - using var reader = new StreamReader(stream); - string actualContent = reader.ReadToEnd(); - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task AES128WithSpecialCharacters_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes128_special_chars.zip"); - const string password = "パスワード123!@#"; // Japanese characters in password - - var entries = new (string Name, string Content)[] - { - ("unicode/chinese.txt", "你好世界 - Hello World in Chinese"), - ("unicode/arabic.txt", "مرحبا بالعالم - Hello World in Arabic"), - ("unicode/emoji.txt", "Hello 👋 World 🌍 with emojis! 🎉"), - ("unicode/mixed.txt", "Ñiño José façade naïve Zürich") - }; - - // Act 1: Create ZIP with AES-128 encrypted Unicode content - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content) in entries) - { - var entry = archive.CreateEntry(name, password, ZipEncryptionMethod.Aes128); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Act 2: Read back and verify Unicode content - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - string actualContent = await reader.ReadToEndAsync(); - - Assert.Equal(expectedContent, actualContent); - } - } - } - - [Fact] - public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes256_async_operations.zip"); - const string entryName = "async_test.txt"; - const string password = "AsyncPass123!"; - const string expectedContent = "This content was written and read asynchronously with AES-256"; - - // Act 1: Create ZIP with AES-256 encrypted entry using async write - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - - // Use async write operations - byte[] contentBytes = Encoding.UTF8.GetBytes(expectedContent); - await entryStream.WriteAsync(contentBytes, 0, contentBytes.Length); - await entryStream.FlushAsync(); - } - - // Act 2: Read back using async operations - string actualContent; - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = await reader.ReadToEndAsync(); - } - - // Assert - Assert.Equal(expectedContent, actualContent); - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "multiple_aes_async_writes.zip"); - - var entries = new (string Name, byte[] Content, string Password, ZipEncryptionMethod Encryption)[] - { - ("async128.bin", Encoding.UTF8.GetBytes("AES-128 async content"), "pass128", ZipEncryptionMethod.Aes128), - ("async192.bin", Encoding.UTF8.GetBytes("AES-192 async content"), "pass192", ZipEncryptionMethod.Aes192), - ("async256.bin", Encoding.UTF8.GetBytes("AES-256 async content"), "pass256", ZipEncryptionMethod.Aes256) - }; - - // Act 1: Create entries with async writes - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - foreach (var (name, content, pwd, encryption) in entries) - { - var entry = archive.CreateEntry(name, pwd, encryption); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(content, 0, content.Length); - await entryStream.FlushAsync(); - } - } - - // Act 2: Read back with async operations - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - foreach (var (name, expectedContent, pwd, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(pwd); - - // Read asynchronously - var buffer = new byte[expectedContent.Length]; - int totalRead = 0; - while (totalRead < buffer.Length) - { - int bytesRead = await entryStream.ReadAsync(buffer, totalRead, buffer.Length - totalRead); - if (bytesRead == 0) break; - totalRead += bytesRead; - } - - Assert.Equal(expectedContent, buffer); - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes128_large_async.zip"); - const string entryName = "large_async.bin"; - const string password = "LargeAsync!@#"; - - // Create larger test data - var random = new Random(123); - var largeData = new byte[256 * 1024]; // 256KB - random.NextBytes(largeData); - - // Act 1: Write large data asynchronously with AES-128 - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal, password, ZipEncryptionMethod.Aes128); - using var entryStream = entry.Open(); - - // Write in chunks asynchronously - const int chunkSize = 8192; - for (int offset = 0; offset < largeData.Length; offset += chunkSize) - { - int writeSize = Math.Min(chunkSize, largeData.Length - offset); - await entryStream.WriteAsync(largeData, offset, writeSize); - } - await entryStream.FlushAsync(); - } - - // Act 2: Read back asynchronously - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var ms = new MemoryStream(); - - // Read in chunks asynchronously - await entryStream.CopyToAsync(ms, bufferSize: 8192); - var actualData = ms.ToArray(); - - // Assert - Assert.Equal(largeData.Length, actualData.Length); - Assert.Equal(largeData, actualData); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes256_multiple_writes.zip"); - const string entryName = "multi_write.txt"; - const string password = "MultiWrite256"; - - var parts = new[] - { - "First part of content\n", - "Second part of content\n", - "Third part of content\n", - "Final part of content" - }; - string expectedContent = string.Join("", parts); - - // Act 1: Write multiple times to same entry - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes256); - using var entryStream = entry.Open(); - - // Write each part asynchronously - foreach (var part in parts) - { - byte[] partBytes = Encoding.UTF8.GetBytes(part); - await entryStream.WriteAsync(partBytes, 0, partBytes.Length); - } - await entryStream.FlushAsync(); - } - - // Act 2: Read back all content - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var reader = new StreamReader(entryStream); - string actualContent = await reader.ReadToEndAsync(); - - // Assert - Assert.Equal(expectedContent, actualContent); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task AsyncReadInChunks_AES128_VerifyContent() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes128_chunked_read.zip"); - const string entryName = "chunked.bin"; - const string password = "ChunkedRead128"; - - // Create recognizable pattern - var pattern = new byte[1024]; - for (int i = 0; i < pattern.Length; i++) - { - pattern[i] = (byte)(i % 256); - } - - // Act 1: Write pattern - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName, password, ZipEncryptionMethod.Aes128); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(pattern, 0, pattern.Length); - } - - // Act 2: Read in small chunks and verify - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - - // Read in 100-byte chunks - const int chunkSize = 16; - var readBuffer = new byte[chunkSize]; - var allData = new List(); - - int bytesRead; - while ((bytesRead = await entryStream.ReadAsync(readBuffer, 0, chunkSize)) > 0) - { - allData.AddRange(readBuffer.Take(bytesRead)); - } - - // Assert - Assert.Equal(pattern, allData.ToArray()); - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task MixedSyncAsyncOperations_AES192_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes192_mixed_ops.zip"); - - var entries = new[] - { - ("sync_write.txt", "Written synchronously", "syncPass"), - ("async_write.txt", "Written asynchronously", "asyncPass") - }; - - // Act 1: Mix sync and async writes - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - // Synchronous write - var syncEntry = archive.CreateEntry(entries[0].Item1, entries[0].Item3, ZipEncryptionMethod.Aes192); - using (var syncStream = syncEntry.Open()) - { - byte[] syncBytes = Encoding.UTF8.GetBytes(entries[0].Item2); - syncStream.Write(syncBytes, 0, syncBytes.Length); - } - - // Asynchronous write - var asyncEntry = archive.CreateEntry(entries[1].Item1, entries[1].Item3, ZipEncryptionMethod.Aes192); - using (var asyncStream = asyncEntry.Open()) - { - byte[] asyncBytes = Encoding.UTF8.GetBytes(entries[1].Item2); - await asyncStream.WriteAsync(asyncBytes, 0, asyncBytes.Length); - } - } - - // Act 2: Read back with mixed operations - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - // Read first entry asynchronously - var entry1 = archive.GetEntry(entries[0].Item1); - Assert.NotNull(entry1); - using (var stream1 = entry1!.Open(entries[0].Item3)) - using (var reader1 = new StreamReader(stream1)) - { - string content1 = await reader1.ReadToEndAsync(); - Assert.Equal(entries[0].Item2, content1); - } - - // Read second entry synchronously - var entry2 = archive.GetEntry(entries[1].Item1); - Assert.NotNull(entry2); - using (var stream2 = entry2!.Open(entries[1].Item3)) - using (var reader2 = new StreamReader(stream2)) - { - string content2 = reader2.ReadToEnd(); - Assert.Equal(entries[1].Item2, content2); - } - } - } - - [SkipOnCI("Local development test - requires specific file paths")] - [Fact] - public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() - { - // Arrange - use a fixed path so you can hexdump it - Directory.CreateDirectory(DownloadsDir); - string archivePath = Path.Combine(DownloadsDir, "debug_update_mode_aes.zip"); - string archiveAfterUpdatePath = Path.Combine(DownloadsDir, "debug_update_mode_aes_after_update.zip"); - - if (File.Exists(archivePath)) File.Delete(archivePath); - if (File.Exists(archiveAfterUpdatePath)) File.Delete(archiveAfterUpdatePath); - - string password = "password123"; - var encryptionMethod = ZipEncryptionMethod.Aes256; - - // Step 1: Create initial archive with 3 encrypted entries - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) - { - var entries = new[] - { - ("file1.txt", "Content 1"), - ("file2.txt", "Content 2"), - ("file3.txt", "Content 3") - }; - - foreach (var (name, content) in entries) - { - var entry = archive.CreateEntry(name, password, encryptionMethod); - using var stream = entry.Open(); - using var writer = new StreamWriter(stream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Copy the original archive for comparison - File.Copy(archivePath, Path.Combine(DownloadsDir, "debug_update_mode_aes_ORIGINAL.zip"), overwrite: true); - - // Step 2: Open in Update mode and modify file2.txt - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) - { - var entry = archive.GetEntry("file2.txt"); - Assert.NotNull(entry); - - // Log entry state before opening - System.Diagnostics.Debug.WriteLine($"Before Open - Entry: {entry.FullName}"); - System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); - System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); - System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); - System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); - - using (var stream = entry.Open(password)) - { - stream.SetLength(0); - byte[] newContent = Encoding.UTF8.GetBytes("Modified Content 2"); - await stream.WriteAsync(newContent, 0, newContent.Length); - } - - // Log all entries' state after modification - foreach (var e in archive.Entries) - { - System.Diagnostics.Debug.WriteLine($"After Modify - Entry: {e.FullName}"); - System.Diagnostics.Debug.WriteLine($" IsEncrypted: {e.IsEncrypted}"); - System.Diagnostics.Debug.WriteLine($" CompressionMethod: {e.CompressionMethod}"); - } - } - - // Copy the modified archive for comparison - File.Copy(archivePath, archiveAfterUpdatePath, overwrite: true); - - // Step 3: Try to read back all entries - System.Diagnostics.Debug.WriteLine("=== Reading back entries ==="); - - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) - { - foreach (var entry in archive.Entries) - { - System.Diagnostics.Debug.WriteLine($"Reading Entry: {entry.FullName}"); - System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); - System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); - System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); - System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); - - try - { - using var stream = entry.Open(password); - using var reader = new StreamReader(stream, Encoding.UTF8); - string content = await reader.ReadToEndAsync(); - System.Diagnostics.Debug.WriteLine($" Content: '{content}'"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($" ERROR: {ex.GetType().Name}: {ex.Message}"); - } - } - } - - // Assert - this is where the original test fails - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) - { - using (var s1 = archive.GetEntry("file1.txt")!.Open(password)) - using (var r1 = new StreamReader(s1)) - { - Assert.Equal("Content 1", await r1.ReadToEndAsync()); - } - - using (var s2 = archive.GetEntry("file2.txt")!.Open(password)) - using (var r2 = new StreamReader(s2)) - { - Assert.Equal("Modified Content 2", await r2.ReadToEndAsync()); - } - - using (var s3 = archive.GetEntry("file3.txt")!.Open(password)) - using (var r3 = new StreamReader(s3)) - { - Assert.Equal("Content 3", await r3.ReadToEndAsync()); - } - } - } - - [SkipOnCI("Local development test - creates archive for manual inspection with WinRAR")] - [Fact] - public async Task Local_UpdateMode_EditAllEntries_MixedEncryption_ForWinRARInspection() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string archivePath = NewPath("update_all_entries_mixed_encryption.zip"); - string archiveBeforeUpdatePath = NewPath("update_all_entries_mixed_encryption_BEFORE.zip"); - - if (File.Exists(archivePath)) File.Delete(archivePath); - if (File.Exists(archiveBeforeUpdatePath)) File.Delete(archiveBeforeUpdatePath); - - string password = "password123"; - - var entries = new[] - { - ("entry_zipcrypto.txt", "Content ZipCrypto", ZipEncryptionMethod.ZipCrypto), - ("entry_aes128.txt", "Content AES-128", ZipEncryptionMethod.Aes128), - ("entry_aes192.txt", "Content AES-192", ZipEncryptionMethod.Aes192), - ("entry_aes256.txt", "Content AES-256", ZipEncryptionMethod.Aes256) - }; - - // Step 1: Create archive with entries using different encryption methods - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) - { - foreach (var (name, content, encryption) in entries) - { - var entry = archive.CreateEntry(name, password, encryption); - using var stream = entry.Open(); - using var writer = new StreamWriter(stream, Encoding.UTF8); - await writer.WriteAsync(content); - } - } - - // Save a copy before modifications - File.Copy(archivePath, archiveBeforeUpdatePath, overwrite: true); - - // Step 2: Open in Update mode and edit ALL entries - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) - { - foreach (var (name, _, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - - using (var stream = entry.Open(password)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes($"Modified {name}"); - await stream.WriteAsync(content, 0, content.Length); - } - } - } - - // Step 3: Verify all entries can be read back - using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) - { - foreach (var (name, _, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - - using var stream = entry.Open(password); - using var reader = new StreamReader(stream, Encoding.UTF8); - string content = await reader.ReadToEndAsync(); - Assert.Equal($"Modified {name}", content); - } - } - - } } } From 7de87d0d01bc97a03221cb2bf87a607334472a56 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 30 Apr 2026 12:18:33 +0200 Subject: [PATCH 68/83] remove unreachable loop and move dispose --- .../System/IO/Compression/WinZipAesStream.cs | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 670984f78f7792..b9735249cfc884 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -488,13 +488,6 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) inputOffset += bytesToProcess; inputCount -= bytesToProcess; } - - // Buffer any remaining bytes - if (inputCount > 0) - { - buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); - _partialBlockBytes += inputCount; - } } private void ThrowIfNotWritable() @@ -562,11 +555,9 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation } } - // Process full blocks - while (inputCount >= BlockSize) + while (inputCount > 0) { int bytesToProcess = Math.Min(inputCount, workBuffer.Length); - bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); @@ -575,13 +566,6 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation inputOffset += bytesToProcess; inputCount -= bytesToProcess; } - - // Buffer any remaining bytes - if (inputCount > 0) - { - buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); - _partialBlockBytes += inputCount; - } } public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) @@ -616,8 +600,6 @@ protected override void Dispose(bool disposing) return; } - _disposed = true; - if (disposing) { try @@ -644,6 +626,7 @@ protected override void Dispose(bool disposing) } finally { + _disposed = true; _aes.Dispose(); _hmac?.Dispose(); From 53aba0b516d13a24c6497d16567cba56721853f8 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 30 Apr 2026 13:59:11 +0200 Subject: [PATCH 69/83] fix copilot comments --- .../src/System/IO/Compression/ZipArchiveEntry.Async.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 5817483d208712..24b6035da548e5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -64,7 +64,7 @@ public Task OpenAsync(FileAccess access, CancellationToken cancellationT /// The allowed values depend on the : /// /// : Only is allowed. - /// : is allowed; is not allowed. The is only used when decrypting existing encrypted entries and is not used when opening a newly created entry for writing. + /// : and are allowed; is not allowed. The is only used when decrypting existing encrypted entries and is not used when opening a newly created entry for writing. /// : All values are allowed for encrypted entries. /// /// @@ -122,6 +122,10 @@ private Task OpenAsyncCore(FileAccess access, ReadOnlySpan passwor case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + // Encrypted entries always require a password for re-encryption, + // even when discarding existing content (write-only access). + if (IsEncrypted && password.IsEmpty && access != FileAccess.Read) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); return access switch { FileAccess.Read => OpenInReadModeAsync(checkOpenable: true, password, cancellationToken), From 22a5c72aad2c18507b60acf126229cf6a529da03 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 7 May 2026 12:04:12 +0200 Subject: [PATCH 70/83] fix trimming warnings --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 83 +++++++++++-------- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 46 +++++----- .../tests/WinZipAesStreamConformanceTests.cs | 18 ++-- .../tests/ZipCryptoStreamConformanceTests.cs | 23 +++-- .../ZipCryptoStreamWrappedConformanceTests.cs | 30 +++---- 5 files changed, 105 insertions(+), 95 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index c4ef13c77f2a1f..9c66cfc64576f2 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -20,22 +20,59 @@ internal sealed class WinZipAesStreamFuzzer : IFuzzer // AES-256 key size in bits; salt size = keySizeBits / 16 = 16 bytes. private const int KeySizeBits = 256; -#pragma warning disable IL2026 // RequiresUnreferencedCode - private static readonly Type _winZipAesStreamType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; - private static readonly Type _winZipAesKeyMaterialType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; -#pragma warning restore IL2026 - // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, // and CreateDelegate cannot handle struct-to-object return covariance. // Use DynamicMethod to emit a wrapper that boxes the struct return value. private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); -#pragma warning disable IL2077 // dynamic access to non-public members - private static readonly CreateKeyDelegate _createKey = CreateBoxingDelegate(); + private static readonly CreateKeyDelegate _createKey; + private static readonly MethodInfo _createMethod; + + // The salt and password verifier properties are needed to prepend a valid header + // so the stream's ReadAndValidateHeaderCore succeeds and decryption logic is reached. + private static readonly PropertyInfo _saltProp; + private static readonly PropertyInfo _verifierProp; + + // Pre-derive key material once with a fixed password and no salt so the fuzzer focuses + // on the stream's decryption/HMAC logic rather than key derivation. + private static readonly object s_keyMaterial; + + // Cache the salt and password verifier bytes for prepending to the fuzz input. + private static readonly byte[] s_salt; + private static readonly byte[] s_verifier; - private static CreateKeyDelegate CreateBoxingDelegate() + static WinZipAesStreamFuzzer() { - MethodInfo createKeyMethod = _winZipAesKeyMaterialType.GetMethod( + Type winZipAesStreamType = Type.GetType("System.IO.Compression.WinZipAesStream, System.IO.Compression")!; + Type winZipAesKeyMaterialType = Type.GetType("System.IO.Compression.WinZipAesKeyMaterial, System.IO.Compression")!; + +#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not AOT-compatible; fuzzers run under CoreCLR only. + _createKey = CreateBoxingDelegate(winZipAesKeyMaterialType); +#pragma warning restore IL3050 + + _createMethod = winZipAesStreamType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool)], + modifiers: null)!; + + _saltProp = winZipAesKeyMaterialType.GetProperty( + "Salt", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + + _verifierProp = winZipAesKeyMaterialType.GetProperty( + "PasswordVerifier", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + + s_keyMaterial = _createKey("fuzz", null, KeySizeBits); + s_salt = (byte[])_saltProp.GetValue(s_keyMaterial)!; + s_verifier = (byte[])_verifierProp.GetValue(s_keyMaterial)!; + } + + private static CreateKeyDelegate CreateBoxingDelegate(Type winZipAesKeyMaterialType) + { + MethodInfo createKeyMethod = winZipAesKeyMaterialType.GetMethod( "Create", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, binder: null, @@ -53,37 +90,11 @@ private static CreateKeyDelegate CreateBoxingDelegate() il.Emit(System.Reflection.Emit.OpCodes.Ldarg_1); il.Emit(System.Reflection.Emit.OpCodes.Ldarg_2); il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, _winZipAesKeyMaterialType); + il.Emit(System.Reflection.Emit.OpCodes.Box, winZipAesKeyMaterialType); il.Emit(System.Reflection.Emit.OpCodes.Ret); return dm.CreateDelegate(); } - private static readonly MethodInfo _createMethod = _winZipAesStreamType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(Stream), _winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool)], - modifiers: null)!; - - // The salt and password verifier properties are needed to prepend a valid header - // so the stream's ReadAndValidateHeaderCore succeeds and decryption logic is reached. - private static readonly PropertyInfo _saltProp = _winZipAesKeyMaterialType.GetProperty( - "Salt", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly PropertyInfo _verifierProp = _winZipAesKeyMaterialType.GetProperty( - "PasswordVerifier", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; -#pragma warning restore IL2077 - - // Pre-derive key material once with a fixed password and no salt so the fuzzer focuses - // on the stream's decryption/HMAC logic rather than key derivation. - private static readonly object s_keyMaterial = _createKey("fuzz", null, KeySizeBits); - - // Cache the salt and password verifier bytes for prepending to the fuzz input. - private static readonly byte[] s_salt = (byte[])_saltProp.GetValue(s_keyMaterial)!; - private static readonly byte[] s_verifier = (byte[])_verifierProp.GetValue(s_keyMaterial)!; - // Minimum fuzz input: at least 1 byte of encrypted data beyond the header. // The header (salt + verifier) is prepended by CreateStream, so the fuzz input // only needs to supply encrypted data + the 10-byte auth code. diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 9a215d8384e599..0765bd6c680525 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -28,22 +28,37 @@ public void FuzzTarget(ReadOnlySpan bytes) TestStream(CopyToRentedArray(bytes), bytes.Length, async: true).GetAwaiter().GetResult(); } -#pragma warning disable IL2026 // RequiresUnreferencedCode - private static readonly Type _zipCryptoStreamType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - private static readonly Type _zipCryptoKeysType = typeof(ZipArchive).Assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; -#pragma warning restore IL2026 - // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, // and CreateDelegate cannot handle struct-to-object return covariance. // Use DynamicMethod to emit a wrapper that boxes the struct return value. private delegate object CreateKeyDelegate(ReadOnlySpan password); -#pragma warning disable IL2077 // dynamic access to non-public members - private static readonly CreateKeyDelegate _createKey = CreateBoxingDelegate(); + private static readonly CreateKeyDelegate _createKey; + private static readonly MethodInfo _createMethod; + private static readonly object s_keys; - private static CreateKeyDelegate CreateBoxingDelegate() + static ZipCryptoStreamFuzzer() { - MethodInfo createKeyMethod = _zipCryptoStreamType.GetMethod( + Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; + Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; + +#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not AOT-compatible; fuzzers run under CoreCLR only. + _createKey = CreateBoxingDelegate(zipCryptoStreamType, zipCryptoKeysType); +#pragma warning restore IL3050 + + _createMethod = zipCryptoStreamType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool)], + modifiers: null)!; + + s_keys = _createKey("fuzz"); + } + + private static CreateKeyDelegate CreateBoxingDelegate(Type zipCryptoStreamType, Type zipCryptoKeysType) + { + MethodInfo createKeyMethod = zipCryptoStreamType.GetMethod( "CreateKey", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; @@ -56,22 +71,11 @@ private static CreateKeyDelegate CreateBoxingDelegate() var il = dm.GetILGenerator(); il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, _zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Box, zipCryptoKeysType); il.Emit(System.Reflection.Emit.OpCodes.Ret); return dm.CreateDelegate(); } - private static readonly MethodInfo _createMethod = _zipCryptoStreamType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(Stream), _zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool)], - modifiers: null)!; -#pragma warning restore IL2077 - - // Derive keys from a fixed password so the key state is realistic. - private static readonly object s_keys = _createKey("fuzz"); - private static Stream CreateStream(byte[] bytes, int length) { // Use the first byte of the input as the "expected check byte" so that the diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 202a96d8d3f40e..9291abd1990cbc 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using System.IO.Tests; using System.Reflection; using System.Runtime.Versioning; @@ -37,8 +36,6 @@ public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConforma { private const string TestPassword = "test-password"; - private static readonly Type s_winZipAesStreamType; - private static readonly Type s_winZipAesKeyMaterialType; private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createMethod; @@ -48,11 +45,10 @@ public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConforma static WinZipAesStreamConformanceTests() { - var assembly = typeof(ZipArchive).Assembly; - s_winZipAesStreamType = assembly.GetType("System.IO.Compression.WinZipAesStream", throwOnError: true)!; - s_winZipAesKeyMaterialType = assembly.GetType("System.IO.Compression.WinZipAesKeyMaterial", throwOnError: true)!; + Type winZipAesStreamType = Type.GetType("System.IO.Compression.WinZipAesStream, System.IO.Compression")!; + Type winZipAesKeyMaterialType = Type.GetType("System.IO.Compression.WinZipAesKeyMaterial, System.IO.Compression")!; - MethodInfo createKeyMethod = s_winZipAesKeyMaterialType.GetMethod("Create", + MethodInfo createKeyMethod = winZipAesKeyMaterialType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(ReadOnlySpan), typeof(byte[]), typeof(int) }, @@ -60,6 +56,7 @@ static WinZipAesStreamConformanceTests() // CreateDelegate can't handle value-type return covariance (struct → object boxing). // Use DynamicMethod to emit a wrapper that calls the target and boxes the result. +#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not supported in AOT; these tests are skipped under NativeAOT. var dm = new System.Reflection.Emit.DynamicMethod( "CreateKeyWrapper", typeof(object), @@ -71,14 +68,15 @@ static WinZipAesStreamConformanceTests() il.Emit(System.Reflection.Emit.OpCodes.Ldarg_1); il.Emit(System.Reflection.Emit.OpCodes.Ldarg_2); il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, s_winZipAesKeyMaterialType); + il.Emit(System.Reflection.Emit.OpCodes.Box, winZipAesKeyMaterialType); il.Emit(System.Reflection.Emit.OpCodes.Ret); s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 - s_createMethod = s_winZipAesStreamType.GetMethod("Create", + s_createMethod = winZipAesStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool) }, null)!; } diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index 861fd42e458c82..b3405643f6c5a1 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using System.IO.Tests; using System.Reflection; -using System.Threading; using System.Threading.Tasks; using Xunit; @@ -19,8 +17,6 @@ public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests private const string TestPassword = "test-password"; private const ushort PasswordVerifier = 0x1234; - private static readonly Type s_zipCryptoStreamType; - private static readonly Type s_zipCryptoKeysType; private delegate object CreateKeyDelegate(ReadOnlySpan password); private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createEncryptionMethod; @@ -28,17 +24,17 @@ public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests static ZipCryptoStreamConformanceTests() { - var assembly = typeof(ZipArchive).Assembly; - s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - s_zipCryptoKeysType = assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; + Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; + Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; - MethodInfo createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", + MethodInfo createKeyMethod = zipCryptoStreamType.GetMethod("CreateKey", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(ReadOnlySpan) }, null)!; // Use DynamicMethod to box the struct return value. +#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not supported in AOT; these tests are skipped under NativeAOT. var dm = new System.Reflection.Emit.DynamicMethod( "CreateKeyWrapper", typeof(object), @@ -48,20 +44,21 @@ static ZipCryptoStreamConformanceTests() var il = dm.GetILGenerator(); il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, s_zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Box, zipCryptoKeysType); il.Emit(System.Reflection.Emit.OpCodes.Ret); s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 - s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", + s_createEncryptionMethod = zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + new[] { typeof(Stream), zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, null)!; - s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", + s_createDecryptionMethod = zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, null)!; } diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 455922ac7172a0..95beee145ec014 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using System.IO.Tests; using System.Reflection; using System.Threading; @@ -20,8 +19,6 @@ public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamCon private const string TestPassword = "test-password"; private const ushort PasswordVerifier = 0x1234; - private static readonly Type s_zipCryptoStreamType; - private static readonly Type s_zipCryptoKeysType; private delegate object CreateKeyDelegate(ReadOnlySpan password); private static readonly CreateKeyDelegate s_createKey; private static readonly MethodInfo s_createEncryptionMethod; @@ -30,17 +27,17 @@ public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamCon static ZipCryptoStreamWrappedConformanceTests() { - var assembly = typeof(ZipArchive).Assembly; - s_zipCryptoStreamType = assembly.GetType("System.IO.Compression.ZipCryptoStream", throwOnError: true)!; - s_zipCryptoKeysType = assembly.GetType("System.IO.Compression.ZipCryptoKeys", throwOnError: true)!; + Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; + Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; - MethodInfo createKeyMethod = s_zipCryptoStreamType.GetMethod("CreateKey", + MethodInfo createKeyMethod = zipCryptoStreamType.GetMethod("CreateKey", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(ReadOnlySpan) }, null)!; // Use DynamicMethod to box the struct return value. +#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not supported in AOT; these tests are skipped under NativeAOT. var dm = new System.Reflection.Emit.DynamicMethod( "CreateKeyWrapper", typeof(object), @@ -50,26 +47,27 @@ static ZipCryptoStreamWrappedConformanceTests() var il = dm.GetILGenerator(); il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, s_zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Box, zipCryptoKeysType); il.Emit(System.Reflection.Emit.OpCodes.Ret); s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 - s_createEncryptionMethod = s_zipCryptoStreamType.GetMethod("Create", + s_createEncryptionMethod = zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + new[] { typeof(Stream), zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, null)!; - s_createDecryptionMethod = s_zipCryptoStreamType.GetMethod("Create", + s_createDecryptionMethod = zipCryptoStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, null)!; - s_createAsyncMethod = s_zipCryptoStreamType.GetMethod("CreateAsync", + s_createAsyncMethod = zipCryptoStreamType.GetMethod("CreateAsync", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), s_zipCryptoKeysType, typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, null); } @@ -132,7 +130,9 @@ private static async Task CreateDecryptStreamAsync(Stream baseStream, ob await task.ConfigureAwait(false); // Get the Result property from the completed task - var resultProperty = task.GetType().GetProperty("Result")!; +#pragma warning disable IL2075 // CreateAsync returns Task; this test is skipped under NativeAOT. + var resultProperty = task.GetType().GetProperty(nameof(Task.Result))!; +#pragma warning restore IL2075 return (Stream)resultProperty.GetValue(task)!; } From 817757885f4944bef5b0cbbd7d1a63e253be3ee6 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 7 May 2026 15:23:26 +0200 Subject: [PATCH 71/83] add tests based on runtime assets --- .../System/IO/Compression/ZipTestHelper.cs | 6 + .../tests/ZipArchive/zip_ReadTests.cs | 140 ++++++++++++++++-- 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a3ccd52901601e..c7b8ab960d2d4e 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -14,6 +14,7 @@ public partial class ZipFileTestBase : FileCleanupTestBase { public static string bad(string filename) => Path.Combine("ZipTestData", "badzipfiles", filename); public static string compat(string filename) => Path.Combine("ZipTestData", "compat", filename); + public static string passwordProtected(string filename) => Path.Combine("ZipTestData", "PasswordProtectedZipArchives", filename); public static string strange(string filename) => Path.Combine("ZipTestData", "StrangeZipFiles", filename); public static string zfile(string filename) => Path.Combine("ZipTestData", "refzipfiles", filename); public static string zfolder(string filename) => Path.Combine("ZipTestData", "refzipfolders", filename); @@ -562,6 +563,11 @@ public static async Task OpenEntryStream(bool async, ZipArchiveEntry ent return async ? await entry.OpenAsync() : entry.Open(); } + public static Task OpenEntryStream(bool async, ZipArchiveEntry entry, string password) + { + return async ? entry.OpenAsync(password) : Task.FromResult(entry.Open(password)); + } + public static async Task DisposeStream(bool async, Stream stream) { if (async) diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index 3ed31d549ae5d5..e08515f3dea86a 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -24,10 +24,10 @@ public NonSeekableStream(Stream baseStream) public override bool CanSeek => false; // Force non-seekable public override bool CanWrite => _baseStream.CanWrite; public override long Length => _baseStream.Length; - public override long Position - { - get => _baseStream.Position; - set => throw new NotSupportedException("Seeking is not supported"); + public override long Position + { + get => _baseStream.Position; + set => throw new NotSupportedException("Seeking is not supported"); } public override void Flush() => _baseStream.Flush(); @@ -312,14 +312,14 @@ public static async Task ReadModeInvalidOpsTest(bool async) Stream s = await OpenEntryStream(async, e); Assert.Throws(() => s.Flush()); //"Should not be able to flush on read stream" Assert.Throws(() => s.WriteByte(25)); //"should not be able to write to read stream" - + // Seeking behavior depends on whether the entry is compressed and the underlying stream is seekable if (!s.CanSeek) { Assert.Throws(() => s.Position = 4); //"should not be able to seek on non-seekable read stream" Assert.Throws(() => s.Seek(0, SeekOrigin.Begin)); //"should not be able to seek on non-seekable read stream" } - + Assert.Throws(() => s.SetLength(0)); //"should not be able to resize read stream" await DisposeZipArchive(async, archive); @@ -591,12 +591,12 @@ public static async Task ReadStreamOps(bool async) Assert.True(s.CanRead, "Can read to read archive"); Assert.False(s.CanWrite, "Can't write to read archive"); - + // Check the entry's compression method to determine seekability // SubReadStream should be seekable when the underlying stream is seekable and the entry is stored (uncompressed) // If the entry is compressed (Deflate, Deflate64, etc.), it will be wrapped in a compression stream which is not seekable ZipCompressionMethod compressionMethod = (ZipCompressionMethod)compressionMethodField.GetValue(e); - + if (compressionMethod == ZipCompressionMethod.Stored) { // Entry is stored (uncompressed), should be seekable @@ -607,7 +607,7 @@ public static async Task ReadStreamOps(bool async) // Entry is compressed (Deflate, Deflate64, etc.), wrapped in compression stream, should not be seekable Assert.False(s.CanSeek, $"Entry '{e.FullName}' with compression method {compressionMethod} should not be seekable because compressed entries are wrapped in non-seekable compression streams"); } - + Assert.Equal(await LengthOfUnseekableStream(s), e.Length); //"Length is not correct on stream" await DisposeStream(async, s); @@ -672,7 +672,7 @@ public static async Task ReadStreamSeekOps(bool async) // Test that seeking before beginning throws, but beyond end is allowed Assert.Throws(() => s.Position = -1); Assert.Throws(() => s.Seek(-1, SeekOrigin.Begin)); - + // Seeking beyond end should be allowed (no exception) s.Position = e.Length + 1; Assert.Equal(e.Length + 1, s.Position); @@ -693,7 +693,7 @@ public static async Task ReadEntryContentTwice(bool async, bool useSeekMethod) using (var ms = new MemoryStream()) { var testData = "This is test data for reading content twice with seeking operations."u8.ToArray(); - + // Create a ZIP with stored entries using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true)) { @@ -966,5 +966,123 @@ public static async Task StrongEncryptionDetectedAsUnknown(bool async) await DisposeZipArchive(async, archive); } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task DecryptEntries_SamePassword_7Zip(bool async) + { + string password = "S3cur3P@ssw0rd"; + using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_7ZIP_SamePassword.zip")); + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Read); + + Assert.Equal(2, archive.Entries.Count); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + Assert.True(entry.IsEncrypted); + + using Stream entryStream = await OpenEntryStream(async, entry, password); + using StreamReader reader = new(entryStream); + string content = reader.ReadToEnd().TrimEnd(); + + if (entry.Name == "hello.txt") + { + Assert.Equal("Hello", content); + } + else if (entry.Name == "goodbye.txt") + { + Assert.Equal("Goodbye", content); + } + else + { + Assert.Fail($"Unexpected entry: {entry.Name}"); + } + } + await DisposeZipArchive(async, archive); + } + + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task DecryptEntries_MixedEncryptions(bool async) + { + string password = "S3cur3P@ssw0rd"; + using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_MixedEncryptions.zip")); + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Read); + + Assert.Equal(2, archive.Entries.Count); + + ZipArchiveEntry helloEntry = archive.GetEntry("hello.txt"); + Assert.NotNull(helloEntry); + Assert.True(helloEntry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.ZipCrypto, helloEntry.EncryptionMethod); + + using (Stream helloStream = await OpenEntryStream(async, helloEntry, password)) + using (StreamReader helloReader = new(helloStream)) + { + Assert.Equal("Hello", helloReader.ReadToEnd().TrimEnd()); + } + + ZipArchiveEntry goodbyeEntry = archive.GetEntry("goodbye.txt"); + Assert.NotNull(goodbyeEntry); + Assert.True(goodbyeEntry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.Aes256, goodbyeEntry.EncryptionMethod); + + using (Stream goodbyeStream = await OpenEntryStream(async, goodbyeEntry, password)) + using (StreamReader goodbyeReader = new(goodbyeStream)) + { + Assert.Equal("Goodbye", goodbyeReader.ReadToEnd().TrimEnd()); + } + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task DecryptEntries_DifferentPasswords(bool async) + { + using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_DifferentPasswords.zip")); + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Read); + + Assert.Equal(2, archive.Entries.Count); + + ZipArchiveEntry helloEntry = archive.GetEntry("hello.txt"); + Assert.NotNull(helloEntry); + Assert.True(helloEntry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.Aes256, helloEntry.EncryptionMethod); + + using (Stream helloStream = await OpenEntryStream(async, helloEntry, "S3cur3P@ssw0rd2")) + using (StreamReader helloReader = new(helloStream)) + { + Assert.Equal("Hello", helloReader.ReadToEnd().TrimEnd()); + } + + ZipArchiveEntry goodbyeEntry = archive.GetEntry("goodbye.txt"); + Assert.NotNull(goodbyeEntry); + Assert.True(goodbyeEntry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.Aes256, goodbyeEntry.EncryptionMethod); + + using (Stream goodbyeStream = await OpenEntryStream(async, goodbyeEntry, "S3cur3P@ssw0rd1")) + using (StreamReader goodbyeReader = new(goodbyeStream)) + { + Assert.Equal("Goodbye", goodbyeReader.ReadToEnd().TrimEnd()); + } + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task PasswordProtectedZip64_UpdateMode_Throws(bool async) + { + using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtectedZIP64.zip")); + + await Assert.ThrowsAsync(async () => + { + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Update); + await DisposeZipArchive(async, archive); + }); + } } } + From 992331a10aa26660ec6d3600e56f16721124b51e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 11 May 2026 13:44:57 +0200 Subject: [PATCH 72/83] address comments --- ...System.IO.Compression.ZipFile.Tests.csproj | 2 +- .../src/System.IO.Compression.csproj | 2 +- .../System/IO/Compression/WinZipAesStream.cs | 75 ++++++++++--------- .../src/System/IO/Compression/ZipBlocks.cs | 51 +++++++++---- .../System/IO/Compression/ZipCryptoStream.cs | 11 ++- .../src/System.Security.Cryptography.csproj | 2 +- 6 files changed, 89 insertions(+), 54 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj index 080a80cb82a1e9..c936a7c15573fa 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj @@ -1,4 +1,4 @@ - + true true diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index afe228afd518a6..86d809bf53f540 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index b9735249cfc884..2b53a347499d59 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -110,8 +110,8 @@ private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStr baseStream.ReadExactly(verifier); } - // Verify the salt matches — use constant-time comparison because the salt is - // derived from secret key material and a timing side-channel could leak information. + // Verify the salt matches. In WinZip AES, the salt is stored in the archive + // header and is not secret; FixedTimeEquals is used here for consistency. if (!CryptographicOperations.FixedTimeEquals(fileSalt, keyMaterial.Salt)) { throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -169,7 +169,8 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon _aes.SetKey(keyMaterial.EncryptionKey); } - private void FinalizeAndCompareHMAC(byte[] storedAuth) { + private void FinalizeAndCompareHMAC(byte[] storedAuth) + { Debug.Assert(_hmac is not null, "HMAC should have been initialized"); @@ -331,7 +332,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel { // WriteAsync requires Memory, so we must copy to a heap buffer for the async path byte[] authCodeArray = authCode.Slice(0, 10).ToArray(); - await _baseStream.WriteAsync(authCodeArray.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(authCodeArray.AsMemory(), cancellationToken).ConfigureAwait(false); } else { @@ -572,6 +573,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo { return WriteAsyncCore(buffer, cancellationToken); } + private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) { // Process any bytes remaining in the partial buffer @@ -606,22 +608,7 @@ protected override void Dispose(bool disposing) { if (_encrypting && !_authCodeFinalized) { - // Ensure header is written even for empty files - if (!_headerWritten) - { - WriteHeader(); - } - - // Encrypt remaining partial data - FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - - // Write Auth Code - WriteAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + FinishEncryptingAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); } } finally @@ -651,22 +638,7 @@ public override async ValueTask DisposeAsync() { if (_encrypting && !_authCodeFinalized) { - // Ensure header is written even for empty files - if (!_headerWritten) - { - await WriteHeaderAsync(CancellationToken.None).ConfigureAwait(false); - } - - // Encrypt remaining partial data - await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); - - // Write Auth Code - await WriteAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); - - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync().ConfigureAwait(false); - } + await FinishEncryptingAsync(isAsync: true, CancellationToken.None).ConfigureAwait(false); } } finally @@ -683,10 +655,41 @@ public override async ValueTask DisposeAsync() _disposed = true; GC.SuppressFinalize(this); } + + /// + /// Completes the encryption sequence: ensures the header is written (even for empty entries), + /// flushes any remaining partial block, appends the HMAC authentication code, and flushes the base stream. + /// + private async Task FinishEncryptingAsync(bool isAsync, CancellationToken cancellationToken) + { + Debug.Assert(_encrypting && !_authCodeFinalized); + + // Ensure header is written even for empty files + if (!_headerWritten) + { + if (isAsync) + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + else + WriteHeader(); + } + + // Encrypt remaining partial data + await FinalizeEncryptionAsync(isAsync, cancellationToken).ConfigureAwait(false); + + // Write Auth Code + await WriteAuthCodeCoreAsync(isAsync, cancellationToken).ConfigureAwait(false); + + if (isAsync) + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + else + _baseStream.Flush(); + } + public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); 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 b0b08a1887bd95..c46124704a820f 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 @@ -711,6 +711,8 @@ internal struct WinZipAesExtraField { public const ushort HeaderId = 0x9901; private const int DataSize = 7; // Vendor version (2) + Vendor ID (2) + AES strength (1) + Compression method (2) + private const byte VendorIdByte0 = (byte)'A'; + private const byte VendorIdByte1 = (byte)'E'; private ushort _vendorVersion = 2; private byte _aesStrength; private ushort _compressionMethod; @@ -743,14 +745,9 @@ public static bool TryGetFromExtraFields(List? extraFields foreach (ZipGenericExtraField field in extraFields) { - if (field.Tag == HeaderId && field.Size >= DataSize) + if (field.Tag == HeaderId && field.Size >= DataSize && + TryParseData(field.Data.AsSpan(0, field.Size), out aesExtraField)) { - byte[] data = field.Data; - aesExtraField = new WinZipAesExtraField( - vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), - aesStrength: data[4], - compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) - ); return true; } } @@ -778,14 +775,9 @@ public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData if (offset + 4 + fieldSize > extraFieldData.Length) break; // Not enough data for this field - if (headerId == HeaderId && fieldSize >= DataSize) + if (headerId == HeaderId && fieldSize >= DataSize && + TryParseData(extraFieldData.Slice(offset + 4, fieldSize), out aesExtraField)) { - ReadOnlySpan data = extraFieldData.Slice(offset + 4, fieldSize); - aesExtraField = new WinZipAesExtraField( - vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), - aesStrength: data[4], - compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) - ); return true; } @@ -795,6 +787,37 @@ public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData return false; } + /// + /// Parses and validates the data payload of a candidate WinZip AES extra field. + /// Validates the vendor ID ("AE"), vendor version (1 or 2), and AES strength (1–3). + /// + /// The raw field data bytes (must be at least bytes). + /// When this method returns true, contains the parsed AES extra field. + /// true if the data represents a valid WinZip AES extra field; otherwise, false. + private static bool TryParseData(ReadOnlySpan data, out WinZipAesExtraField aesExtraField) + { + aesExtraField = default; + + ushort vendorVersion = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)); + byte aesStrength = data[4]; + + // Validate vendor ID must be "AE", vendor version must be 1 or 2, + // and AES strength must be 1 (128-bit), 2 (192-bit), or 3 (256-bit). + if (data[2] != VendorIdByte0 || data[3] != VendorIdByte1 || + vendorVersion is < 1 or > 2 || + aesStrength is < 1 or > 3) + { + return false; + } + + aesExtraField = new WinZipAesExtraField( + vendorVersion: vendorVersion, + aesStrength: aesStrength, + compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) + ); + return true; + } + public void WriteBlock(Stream stream) { Span buffer = stackalloc byte[TotalSize]; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 3cabbec717195e..49d6256e631e6e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -289,7 +289,11 @@ public override long Position get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() => _base.Flush(); + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _base.Flush(); + } public override int Read(byte[] buffer, int offset, int count) { @@ -299,6 +303,7 @@ public override int Read(byte[] buffer, int offset, int count) public override int Read(Span destination) { + ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) { throw new NotSupportedException(SR.ReadingNotSupported); @@ -321,6 +326,7 @@ public override void Write(byte[] buffer, int offset, int count) public override void Write(ReadOnlySpan buffer) { + ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) { throw new NotSupportedException(SR.WritingNotSupported); @@ -404,6 +410,7 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) { throw new NotSupportedException(SR.ReadingNotSupported); @@ -427,6 +434,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) { throw new NotSupportedException(SR.WritingNotSupported); @@ -459,6 +467,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella public override Task FlushAsync(CancellationToken cancellationToken) { + ObjectDisposedException.ThrowIf(_disposed, this); return _base.FlushAsync(cancellationToken); } diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index 37b0972651cf2b..b7c393b0368f36 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent) From 3a69edd05cfcb0af24b3ca310b07acb5a16a4057 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 12 May 2026 13:24:43 +0200 Subject: [PATCH 73/83] solve stackalloc unsafe exceptions --- .../src/System/IO/Compression/WinZipAesKeyMaterial.cs | 2 +- .../src/System/IO/Compression/WinZipAesStream.cs | 10 ++++------ .../src/System/IO/Compression/ZipArchiveEntry.cs | 2 +- .../src/System/IO/Compression/ZipBlocks.cs | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 479cba91811a6d..8f112454fe0fae 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -70,7 +70,7 @@ internal static WinZipAesKeyMaterial Parse(byte[] keyMaterial, int keySizeBits) /// /// Derives key material from a password and optional salt using PBKDF2-SHA1. /// - internal static WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? salt, int keySizeBits) + internal static unsafe WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? salt, int keySizeBits) { int saltSize = GetSaltSize(keySizeBits); int keySizeBytes = keySizeBits / 8; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 2b53a347499d59..41be032b047f55 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -169,7 +169,7 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon _aes.SetKey(keyMaterial.EncryptionKey); } - private void FinalizeAndCompareHMAC(byte[] storedAuth) + private unsafe void FinalizeAndCompareHMAC(byte[] storedAuth) { Debug.Assert(_hmac is not null, "HMAC should have been initialized"); @@ -320,8 +320,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel return; } - // Finalize HMAC computation using stackalloc to avoid heap allocation - Span authCode = stackalloc byte[20]; // SHA1 hash size + byte[] authCode = new byte[20]; // SHA1 hash size if (!_hmac.TryGetHashAndReset(authCode, out int bytesWritten) || bytesWritten < 10) { throw new CryptographicException(); @@ -331,12 +330,11 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel if (isAsync) { // WriteAsync requires Memory, so we must copy to a heap buffer for the async path - byte[] authCodeArray = authCode.Slice(0, 10).ToArray(); - await _baseStream.WriteAsync(authCodeArray.AsMemory(), cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); } else { - _baseStream.Write(authCode.Slice(0, 10)); + _baseStream.Write(authCode.AsSpan(0, 10)); } _authCodeFinalized = true; 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 930c0f4425ea7e..abdc3f0deac87c 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 @@ -1697,7 +1697,7 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite, bool preser return zip64ExtraField != null; } - private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) + private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) { // Check if the entry's stored data was actually modified (StoredData flag is set). // If _storedUncompressedData is loaded but StoredData is not set, it means the entry 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 c46124704a820f..4a1f0ff089e60f 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 @@ -818,7 +818,7 @@ vendorVersion is < 1 or > 2 || return true; } - public void WriteBlock(Stream stream) + public unsafe void WriteBlock(Stream stream) { Span buffer = stackalloc byte[TotalSize]; WriteBlockCore(buffer); From c423b7944a9e86c171e1567a065f7845be9c8c4a Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 12 May 2026 16:04:41 +0200 Subject: [PATCH 74/83] add missing unsafe keyword --- .../src/System/IO/Compression/ZipCryptoStream.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 49d6256e631e6e..839a4a4ccb12c0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -118,7 +118,7 @@ private ZipCryptoStream(Stream baseStream, // Creates the persisted key material from a password. // Returns a struct of 3 integers to keep the key off the heap. - internal static ZipCryptoKeys CreateKey(ReadOnlySpan password) + internal static unsafe ZipCryptoKeys CreateKey(ReadOnlySpan password) { // Initialize keys with standard ZipCrypto initial values uint key0 = 305419896; @@ -185,7 +185,7 @@ private void CalculateHeader(Span header) } } - private void WriteHeader() + private unsafe void WriteHeader() { if (!_encrypting || _headerWritten) return; From 5c6bb1c63b9fcfcb1fa2b26d6d4e090383fae06b Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 23 Jun 2026 11:45:11 +0300 Subject: [PATCH 75/83] address comments and fix coding style --- .../System/IO/Compression/ZipTestHelper.cs | 25 +++++++- .../Fuzzers/WinZipAesStreamFuzzer.cs | 14 +---- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 14 +---- .../IO/Compression/ZipFile.Create.Async.cs | 4 ++ .../System/IO/Compression/ZipFile.Create.cs | 4 ++ ...pFileExtensions.ZipArchiveEntry.Extract.cs | 8 +++ .../IO/Compression/WinZipAesKeyMaterial.cs | 5 +- .../System/IO/Compression/WinZipAesStream.cs | 58 ++++++++----------- .../System/IO/Compression/ZipArchive.Async.cs | 2 +- .../src/System/IO/Compression/ZipArchive.cs | 30 ++++++++++ .../IO/Compression/ZipArchiveEntry.Async.cs | 18 ++++++ .../System/IO/Compression/ZipArchiveEntry.cs | 58 +++++++++++++++++++ .../src/System/IO/Compression/ZipBlocks.cs | 4 ++ .../System/IO/Compression/ZipCryptoStream.cs | 26 +++++---- .../tests/ZipArchive/zip_ReadTests.cs | 23 ++++++-- 15 files changed, 216 insertions(+), 77 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index c7b8ab960d2d4e..fa80c1156e4b45 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -64,8 +64,9 @@ await stream.ReadAsync(buffer, totalBytesRead, bytesLeftToRead) : stream.Read(buffer, totalBytesRead, bytesLeftToRead); if (bytesRead == 0) throw new IOException("Unexpected end of stream"); - + { totalBytesRead += bytesRead; + } bytesLeftToRead -= bytesRead; } } @@ -98,7 +99,9 @@ public static bool ArraysEqual(T[] a, T[] b) where T : IComparable for (int i = 0; i < a.Length; i++) { if (a[i].CompareTo(b[i]) != 0) return false; + { } + } return true; } @@ -107,7 +110,9 @@ public static bool ArraysEqual(T[] a, T[] b, int length) where T : IComparabl for (int i = 0; i < length; i++) { if (a[i].CompareTo(b[i]) != 0) return false; + { } + } return true; } @@ -119,9 +124,13 @@ public static void StreamsEqual(Stream ast, Stream bst) public static void StreamsEqual(Stream ast, Stream bst, int blocksToRead) { if (ast.CanSeek) + { ast.Seek(0, SeekOrigin.Begin); + } if (bst.CanSeek) + { bst.Seek(0, SeekOrigin.Begin); + } const int bufSize = 4096; byte[] ad = new byte[bufSize]; @@ -136,7 +145,9 @@ public static void StreamsEqual(Stream ast, Stream bst, int blocksToRead) do { if (blocksToRead != -1 && blocksRead >= blocksToRead) + { break; + } ac = ReadAllBytes(ast, ad, 0, 4096); bc = ReadAllBytes(bst, bd, 0, 4096); @@ -155,9 +166,13 @@ public static void StreamsEqual(Stream ast, Stream bst, int blocksToRead) public static async Task StreamsEqualAsync(Stream ast, Stream bst, int blocksToRead) { if (ast.CanSeek) + { ast.Seek(0, SeekOrigin.Begin); + } if (bst.CanSeek) + { bst.Seek(0, SeekOrigin.Begin); + } const int bufSize = 4096; byte[] ad = new byte[bufSize]; @@ -172,7 +187,9 @@ public static async Task StreamsEqualAsync(Stream ast, Stream bst, int blocksToR do { if (blocksToRead != -1 && blocksRead >= blocksToRead) + { break; + } ac = await ast.ReadAtLeastAsync(ad, 4096, throwOnEndOfStream: false); bc = await bst.ReadAtLeastAsync(bd, 4096, throwOnEndOfStream: false); @@ -217,7 +234,9 @@ public static async Task IsZipSameAsDir(Stream archiveFile, string directory, Zi count++; string entryName = file.FullName; if (file.IsFolder) + { entryName += Path.DirectorySeparatorChar; + } ZipArchiveEntry entry = archive.GetEntry(entryName); if (entry == null) { @@ -279,7 +298,9 @@ public static async Task IsZipSameAsDir(Stream archiveFile, string directory, Zi } if ((!requireExplicit && !isEmpty) || entryName.Contains("emptydir")) + { count--; //discount this entry + } } else { @@ -368,7 +389,9 @@ private static async Task ItemEqual(string[] actualList, List expected string bName = Path.GetFileName(bEntry); // expected 'emptydir' folder doesn't exist because MSBuild doesn't copy empty dir if (!isFile && aName.Contains("emptydir") && bName.Contains("emptydir")) + { continue; + } //we want it to be false that one of them is a directory and the other isn't Assert.False(Directory.Exists(aEntry) ^ Directory.Exists(bEntry), "Directory in one is file in other"); diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 9c66cfc64576f2..7632e9b6b33197 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -129,19 +129,11 @@ private static Stream CreateStream(byte[] bytes, int length) #pragma warning restore IL2072 } - private byte[] CopyToRentedArray(ReadOnlySpan bytes) + private static byte[] CopyToRentedArray(ReadOnlySpan bytes) { byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - try - { - bytes.CopyTo(buffer); - return buffer; - } - catch - { - ArrayPool.Shared.Return(buffer); - throw; - } + bytes.CopyTo(buffer); + return buffer; } private async Task TestStream(byte[] buffer, int length, bool async) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 0765bd6c680525..71a6851d37659e 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -89,19 +89,11 @@ private static Stream CreateStream(byte[] bytes, int length) #pragma warning restore IL2072 } - private byte[] CopyToRentedArray(ReadOnlySpan bytes) + private static byte[] CopyToRentedArray(ReadOnlySpan bytes) { byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - try - { - bytes.CopyTo(buffer); - return buffer; - } - catch - { - ArrayPool.Shared.Return(buffer); - throw; - } + bytes.CopyTo(buffer); + return buffer; } private async Task TestStream(byte[] buffer, int length, bool async) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs index 8d2bd86ee2427f..336d06557ac2e6 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs @@ -443,7 +443,9 @@ public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, st { ArgumentNullException.ThrowIfNull(options); if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + { throw new ArgumentException(SR.EmptyPassword, nameof(options)); + } cancellationToken.ThrowIfCancellationRequested(); (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); @@ -468,7 +470,9 @@ public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, St { ArgumentNullException.ThrowIfNull(options); if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + { throw new ArgumentException(SR.EmptyPassword, nameof(options)); + } cancellationToken.ThrowIfCancellationRequested(); sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs index 36b11871eefa8c..85b72d848e650b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs @@ -412,7 +412,9 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin { ArgumentNullException.ThrowIfNull(options); if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + { throw new ArgumentException(SR.EmptyPassword, nameof(options)); + } (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); @@ -432,7 +434,9 @@ public static void CreateFromDirectory(string sourceDirectoryName, Stream destin { ArgumentNullException.ThrowIfNull(options); if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + { throw new ArgumentException(SR.EmptyPassword, nameof(options)); + } sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 7cfd6df9c39ff1..296a3f76b119dd 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -226,12 +226,16 @@ private static bool ExtractRelativeToDirectoryCheckIfFile(ZipArchiveEntry source fileDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, ArchivingUtils.SanitizeEntryFilePath(source.FullName))); if (!fileDestinationPath.StartsWith(destinationDirectoryFullPath, PathInternal.StringComparison)) + { throw new IOException(SR.IO_ExtractingResultsInOutside); + } if (Path.GetFileName(fileDestinationPath).Length == 0) { if (source.Length != 0) + { throw new IOException(SR.IO_DirectoryNameWithData); + } Directory.CreateDirectory(fileDestinationPath); @@ -249,9 +253,13 @@ internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, str // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); if (!password.IsEmpty) + { ExtractToFile(source, fileDestinationPath, overwrite, password); + } else + { source.ExtractToFile(fileDestinationPath, overwrite: overwrite); + } } } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 8f112454fe0fae..06667c714cabd5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Diagnostics; using System.Runtime.Versioning; using System.Security.Cryptography; @@ -89,7 +90,7 @@ internal static unsafe WinZipAesKeyMaterial Create(ReadOnlySpan password, } int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); - byte[] rentedPasswordBytes = System.Buffers.ArrayPool.Shared.Rent(maxPasswordByteCount); + byte[] rentedPasswordBytes = ArrayPool.Shared.Rent(maxPasswordByteCount); Debug.Assert(totalKeySize <= 66, "totalKeySize should be at most 66 bytes (AES-256: 32 + 32 + 2)"); Span derivedKey = stackalloc byte[totalKeySize]; @@ -117,7 +118,7 @@ internal static unsafe WinZipAesKeyMaterial Create(ReadOnlySpan password, { CryptographicOperations.ZeroMemory(rentedPasswordBytes); CryptographicOperations.ZeroMemory(derivedKey); - System.Buffers.ArrayPool.Shared.Return(rentedPasswordBytes); + ArrayPool.Shared.Return(rentedPasswordBytes); } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 41be032b047f55..fdd5f95f291018 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -454,18 +454,14 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) { - int inputOffset = 0; - int inputCount = buffer.Length; - // Fill the partial block buffer if it has data if (_partialBlockBytes > 0) { - int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); - buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); + int copyCount = Math.Min(BlockSize - _partialBlockBytes, buffer.Length); + buffer[..copyCount].CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); _partialBlockBytes += copyCount; - inputOffset += copyCount; - inputCount -= copyCount; + buffer = buffer[copyCount..]; // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) @@ -476,16 +472,15 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) } } - while (inputCount > 0) + while (!buffer.IsEmpty) { - int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + int bytesToProcess = Math.Min(buffer.Length, workBuffer.Length); - buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + buffer[..bytesToProcess].CopyTo(workBuffer); ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); _baseStream.Write(workBuffer, 0, bytesToProcess); - inputOffset += bytesToProcess; - inputCount -= bytesToProcess; + buffer = buffer[bytesToProcess..]; } } @@ -531,19 +526,16 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); } - int inputOffset = 0; - int inputCount = buffer.Length; byte[] workBuffer = GetWriteWorkBuffer(); // Fill the partial block buffer if it has data if (_partialBlockBytes > 0) { - int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); - buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + int copyCount = Math.Min(BlockSize - _partialBlockBytes, buffer.Length); + buffer[..copyCount].CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); _partialBlockBytes += copyCount; - inputOffset += copyCount; - inputCount -= copyCount; + buffer = buffer[copyCount..]; // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) @@ -554,16 +546,15 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation } } - while (inputCount > 0) + while (!buffer.IsEmpty) { - int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + int bytesToProcess = Math.Min(buffer.Length, workBuffer.Length); - buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + buffer[..bytesToProcess].CopyTo(workBuffer); ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); - inputOffset += bytesToProcess; - inputCount -= bytesToProcess; + buffer = buffer[bytesToProcess..]; } } @@ -666,9 +657,13 @@ private async Task FinishEncryptingAsync(bool isAsync, CancellationToken cancell if (!_headerWritten) { if (isAsync) + { await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + } else + { WriteHeader(); + } } // Encrypt remaining partial data @@ -678,9 +673,13 @@ private async Task FinishEncryptingAsync(bool isAsync, CancellationToken cancell await WriteAuthCodeCoreAsync(isAsync, cancellationToken).ConfigureAwait(false); if (isAsync) + { await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } else + { _baseStream.Flush(); + } } public override bool CanRead => !_encrypting && !_disposed; @@ -697,22 +696,13 @@ public override long Position public override void Flush() { ObjectDisposedException.ThrowIf(_disposed, this); - - // Only flush if the base stream supports writing - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + _baseStream.Flush(); } public override async Task FlushAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); 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 880c9cb35c2329..da7b1837da3da3 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 @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; 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 242eeac4639f50..ea9cc0e13e86e6 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 @@ -230,7 +230,9 @@ public ReadOnlyCollection Entries get { if (_mode == ZipArchiveMode.Create) + { throw new NotSupportedException(SR.EntriesInCreateMode); + } ThrowIfDisposed(); @@ -393,7 +395,9 @@ protected virtual void Dispose(bool disposing) ArgumentNullException.ThrowIfNull(entryName); if (_mode == ZipArchiveMode.Create) + { throw new NotSupportedException(SR.EntriesInCreateMode); + } EnsureCentralDirectoryRead(); _entriesDictionary.TryGetValue(entryName, out ZipArchiveEntry? result); @@ -466,7 +470,9 @@ private bool IsModified foreach (ZipArchiveEntry entry in _entries) { if (!entry.OriginallyInArchive || entry.Changes != ChangeState.Unchanged) + { return true; + } } return false; @@ -478,7 +484,9 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ArgumentException.ThrowIfNullOrEmpty(entryName); if (_mode == ZipArchiveMode.Read) + { throw new NotSupportedException(SR.CreateInReadMode); + } ThrowIfDisposed(); @@ -556,7 +564,9 @@ private void CloseStreams() // us to _backingStream (which they requested we leave open), and _archiveStream was // the temporary copy that we needed if (_backingStream != null) + { _archiveStream.Dispose(); + } } } @@ -701,13 +711,17 @@ private void ReadCentralDirectory() private void ReadEndOfCentralDirectoryInnerWork(ZipEndOfCentralDirectoryBlock eocd) { if (eocd.NumberOfThisDisk != eocd.NumberOfTheDiskWithTheStartOfTheCentralDirectory) + { throw new InvalidDataException(SR.SplitSpanned); + } _numberOfThisDisk = eocd.NumberOfThisDisk; _centralDirectoryStart = eocd.OffsetOfStartOfCentralDirectoryWithRespectToTheStartingDiskNumber; if (eocd.NumberOfEntriesInTheCentralDirectory != eocd.NumberOfEntriesInTheCentralDirectoryOnThisDisk) + { throw new InvalidDataException(SR.SplitSpanned); + } _expectedNumberOfEntries = eocd.NumberOfEntriesInTheCentralDirectory; @@ -760,12 +774,16 @@ private void ReadEndOfCentralDirectory() private void TryReadZip64EndOfCentralDirectoryInnerInitialWork(Zip64EndOfCentralDirectoryLocator? locator) { if (locator == null || locator.OffsetOfZip64EOCD > long.MaxValue) + { throw new InvalidDataException(SR.FieldTooBigOffsetToZip64EOCD); + } long zip64EOCDOffset = (long)locator.OffsetOfZip64EOCD; if (zip64EOCDOffset < 0 || zip64EOCDOffset > _archiveStream.Length) + { throw new InvalidDataException(SR.InvalidOffsetToZip64EOCD); + } _archiveStream.Seek(zip64EOCDOffset, SeekOrigin.Begin); } @@ -775,13 +793,19 @@ private void TryReadZip64EndOfCentralDirectoryInnerFinalWork(Zip64EndOfCentralDi _numberOfThisDisk = record.NumberOfThisDisk; if (record.NumberOfEntriesTotal > long.MaxValue) + { throw new InvalidDataException(SR.FieldTooBigNumEntries); + } if (record.OffsetOfCentralDirectory > long.MaxValue) + { throw new InvalidDataException(SR.FieldTooBigOffsetToCD); + } if (record.NumberOfEntriesTotal != record.NumberOfEntriesOnThisDisk) + { throw new InvalidDataException(SR.SplitSpanned); + } _expectedNumberOfEntries = (long)record.NumberOfEntriesTotal; _centralDirectoryStart = (long)record.OffsetOfCentralDirectory; @@ -1011,11 +1035,15 @@ private static bool ValidateMode(ZipArchiveMode mode, Stream stream) { case ZipArchiveMode.Create: if (!stream.CanWrite) + { throw new ArgumentException(SR.CreateModeCapabilities); + } break; case ZipArchiveMode.Read: if (!stream.CanRead) + { throw new ArgumentException(SR.ReadModeCapabilities); + } if (!stream.CanSeek) { isReadModeAndUnseekable = true; @@ -1023,7 +1051,9 @@ private static bool ValidateMode(ZipArchiveMode mode, Stream stream) break; case ZipArchiveMode.Update: if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek) + { throw new ArgumentException(SR.UpdateModeCapabilities); + } break; default: // still have to throw this, because stream constructor doesn't do mode argument checks diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 399ad6e1bb23f8..0e6fe7b3664abc 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -79,7 +79,9 @@ public Task OpenAsync(FileAccess access, ReadOnlySpan password, Ca ValidateAccessForMode(access); if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } return OpenAsyncCore(access, password, cancellationToken); } @@ -104,7 +106,9 @@ public Task OpenAsync(ReadOnlySpan password, CancellationToken can ThrowIfInvalidArchive(); if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } return OpenAsyncCore(InferAccessFromMode(), password, cancellationToken); } @@ -125,7 +129,9 @@ private Task OpenAsyncCore(FileAccess access, ReadOnlySpan passwor // Encrypted entries always require a password for re-encryption, // even when discarding existing content (write-only access). if (IsEncrypted && password.IsEmpty && access != FileAccess.Read) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } return access switch { FileAccess.Read => OpenInReadModeAsync(checkOpenable: true, password, cancellationToken), @@ -150,7 +156,9 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance // Skip the local file header to get to the compressed data // TrySkipBlockAsync handles both AES and non-AES cases correctly if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + { throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } @@ -245,7 +253,9 @@ private Task OpenInReadModeAsync(bool checkOpenable, ReadOnlySpan async Task OpenInReadModeAsyncCore(bool checkOpenable, WinZipAesKeyMaterial? aesKeys, ZipCryptoKeys? zipCryptoKeys, byte zipCryptoCheckByte, CancellationToken cancellationToken) { if (checkOpenable) + { await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + } long offset = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offset, _compressedSize); @@ -279,10 +289,14 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent { cancellationToken.ThrowIfCancellationRequested(); if (_currentlyOpenForWrite) + { throw new IOException(SR.UpdateModeOneStream); + } if (Encryption == ZipEncryptionMethod.Unknown) + { throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } if (loadExistingContent) { @@ -455,7 +469,9 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo cancellationToken.ThrowIfCancellationRequested(); (bool openable, string? message) = await IsOpenableAsync(needToUncompress, needToLoadIntoMemory, cancellationToken).ConfigureAwait(false); if (!openable) + { throw new InvalidDataException(message); + } } /// @@ -563,7 +579,9 @@ private async Task StoreDecryptedDataForUpdateAsync(Stream decryptedStre _currentlyOpenForWrite = true; if (_uncompressedSize > Array.MaxLength) + { throw new InvalidDataException(SR.EntryTooLarge); + } _storedUncompressedData = new MemoryStream((int)_uncompressedSize); 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 230fdddabf2672..85c69760d50bf8 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 @@ -196,7 +196,9 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) _fileComment = Array.Empty(); if (_storedEntryNameBytes.Length > ushort.MaxValue) + { throw new ArgumentException(SR.EntryNamesTooLong); + } // grab the stream if we're in create mode if (_archive.Mode == ZipArchiveMode.Create) @@ -241,9 +243,13 @@ public ZipCompressionMethod CompressionMethod private set { if (value == ZipCompressionMethod.Deflate) + { VersionToExtractAtLeast(ZipVersionNeededValues.Deflate); + } else if (value == ZipCompressionMethod.Deflate64) + { VersionToExtractAtLeast(ZipVersionNeededValues.Deflate64); + } _storedCompressionMethod = value; } } @@ -257,7 +263,9 @@ public long CompressedLength get { if (_everOpenedForWrite) + { throw new InvalidOperationException(SR.LengthAfterWrite); + } return _compressedSize; } } @@ -358,11 +366,17 @@ public DateTimeOffset LastWriteTime { ThrowIfInvalidArchive(); if (_archive.Mode == ZipArchiveMode.Read) + { throw new NotSupportedException(SR.ReadOnlyArchive); + } if (_archive.Mode == ZipArchiveMode.Create && _everOpenedForWrite) + { throw new IOException(SR.FrozenAfterWrite); + } if (value.DateTime.Year < ZipHelper.ValidZipDate_YearMin || value.DateTime.Year > ZipHelper.ValidZipDate_YearMax) + { throw new ArgumentOutOfRangeException(nameof(value), SR.DateTimeOutOfRange); + } _lastModified = value; Changes |= ZipArchive.ChangeState.FixedLengthMetadata; @@ -378,7 +392,9 @@ public long Length get { if (_everOpenedForWrite) + { throw new InvalidOperationException(SR.LengthAfterWrite); + } return _uncompressedSize; } } @@ -403,13 +419,19 @@ public long Length public void Delete() { if (_archive == null) + { return; + } if (_currentlyOpenForWrite) + { throw new IOException(SR.DeleteOpenEntry); + } if (_archive.Mode != ZipArchiveMode.Update) + { throw new NotSupportedException(SR.DeleteOnlyInUpdate); + } _archive.ThrowIfDisposed(); @@ -447,7 +469,9 @@ public Stream Open(ReadOnlySpan password) ThrowIfInvalidArchive(); if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } return OpenCore(InferAccessFromMode(), password); } @@ -483,7 +507,9 @@ public Stream Open(FileAccess access, ReadOnlySpan password) ValidateAccessForMode(access); if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } return OpenCore(access, password); } @@ -498,17 +524,23 @@ public Stream Open(FileAccess access, ReadOnlySpan password) private void ValidateAccessForMode(FileAccess access) { if (access is not (FileAccess.Read or FileAccess.Write or FileAccess.ReadWrite)) + { throw new ArgumentOutOfRangeException(nameof(access), SR.InvalidFileAccess); + } switch (_archive.Mode) { case ZipArchiveMode.Read: if (access != FileAccess.Read) + { throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + } break; case ZipArchiveMode.Create: if (access == FileAccess.Read) + { throw new InvalidOperationException(SR.CannotBeReadInCreateMode); + } break; } } @@ -562,7 +594,9 @@ internal long GetOffsetOfCompressedData() // Skip the local file header to get to the compressed data // TrySkipBlock handles both AES and non-AES cases correctly if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + { throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } @@ -918,7 +952,9 @@ internal void LoadCompressedBytesIfNeeded() internal void ThrowIfNotOpenable(bool needToUncompress, bool needToLoadIntoMemory) { if (!IsOpenable(needToUncompress, needToLoadIntoMemory, out string? message)) + { throw new InvalidDataException(message); + } } private void DetectEntryNameVersion() @@ -986,7 +1022,9 @@ private byte CalculateZipCryptoCheckByte() { // If data descriptor NOT used, the check byte is the MSB of CRC32 if ((_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + { return (byte)((_crc32 >> 24) & 0xFF); + } // If data descriptor IS used, the check byte is the MSB of the DOS time from the *local* header return (byte)((ZipHelper.DateTimeToDosTime(_lastModified.DateTime) >> 8) & 0xFF); @@ -1089,7 +1127,9 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) private Stream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = default) { if (checkOpenable) + { ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); + } return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } @@ -1121,14 +1161,18 @@ private Stream BuildDecompressionPipeline(Stream streamToDecompress) // AE-2 encrypted entries store CRC as 0 so skip CRC validation for those. // AE-1 version entries store a valid CRC. if (IsAesEncrypted && _aeVersion == 2) + { return decompressedStream; + } return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); } private WrappedStream OpenInWriteMode() { if (_everOpenedForWrite) + { throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); + } // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded _archive.DebugAssertIsStillArchiveStreamOwner(this); @@ -1205,15 +1249,21 @@ private WrappedStream OpenInWriteModeCore() private WrappedStream OpenInUpdateMode(bool loadExistingContent = true, ReadOnlySpan password = default) { if (_currentlyOpenForWrite) + { throw new IOException(SR.UpdateModeOneStream); + } if (Encryption == ZipEncryptionMethod.Unknown) + { throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } // Encrypted entries always require a password for re-encryption, // even when discarding existing content (write-only access). if (IsEncrypted && password.IsEmpty) + { throw new ArgumentException(SR.PasswordRequired, nameof(password)); + } if (loadExistingContent) { @@ -2107,7 +2157,9 @@ private void VersionToExtractAtLeast(ZipVersionNeededValues value) private void ThrowIfInvalidArchive() { if (_archive == null) + { throw new InvalidOperationException(SR.DeletedEntry); + } _archive.ThrowIfDisposed(); } @@ -2189,7 +2241,9 @@ public override long Position private void ThrowIfDisposed() { if (_isDisposed) + { throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName); + } } public override int Read(byte[] buffer, int offset, int count) @@ -2233,7 +2287,9 @@ public override void Write(byte[] buffer, int offset, int count) // if we're not actually writing anything, we don't want to trigger the header if (count == 0) + { return; + } if (!_everWritten) { @@ -2253,7 +2309,9 @@ public override void Write(ReadOnlySpan source) // if we're not actually writing anything, we don't want to trigger the header if (source.Length == 0) + { return; + } if (!_everWritten) { 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 ea97d2afdfef23..1aec902aeb653a 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 @@ -744,7 +744,9 @@ public static bool TryGetFromExtraFields(List? extraFields aesExtraField = default; if (extraFields == null) + { return false; + } foreach (ZipGenericExtraField field in extraFields) { @@ -776,7 +778,9 @@ public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset + 2, 2)); if (offset + 4 + fieldSize > extraFieldData.Length) + { break; // Not enough data for this field + } if (headerId == HeaderId && fieldSize >= DataSize && TryParseData(extraFieldData.Slice(offset + 4, fieldSize), out aesExtraField)) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 839a4a4ccb12c0..ac76c768f34f14 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -157,7 +157,9 @@ internal static unsafe ZipCryptoKeys CreateKey(ReadOnlySpan password) private void CalculateHeader(Span header) { if (header.Length < 12) + { throw new ArgumentException("Header must be at least 12 bytes.", nameof(header)); + } // bytes 0..9 random RandomNumberGenerator.Fill(header.Slice(0, 10)); @@ -188,7 +190,9 @@ private void CalculateHeader(Span header) private unsafe void WriteHeader() { if (!_encrypting || _headerWritten) + { return; + } Span header = stackalloc byte[12]; CalculateHeader(header); @@ -199,7 +203,9 @@ private unsafe void WriteHeader() private async ValueTask WriteHeaderAsync(CancellationToken cancellationToken) { if (!_encrypting || _headerWritten) + { return; + } byte[] header = new byte[12]; CalculateHeader(header); @@ -335,22 +341,21 @@ public override void Write(ReadOnlySpan buffer) EnsureHeader(); byte[] workBuffer = GetWriteWorkBuffer(); - ReadOnlySpan remaining = buffer; - while (!remaining.IsEmpty) + while (!buffer.IsEmpty) { - int chunkSize = Math.Min(remaining.Length, workBuffer.Length); + int chunkSize = Math.Min(buffer.Length, workBuffer.Length); for (int i = 0; i < chunkSize; i++) { byte ks = DecryptByte(_key2); - byte p = remaining[i]; + byte p = buffer[i]; workBuffer[i] = (byte)(p ^ ks); UpdateKeys(ref _key0, ref _key1, ref _key2, p); } _base.Write(workBuffer, 0, chunkSize); - remaining = remaining.Slice(chunkSize); + buffer = buffer[chunkSize..]; } } @@ -445,23 +450,22 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella await EnsureHeaderAsync(cancellationToken).ConfigureAwait(false); byte[] workBuffer = GetWriteWorkBuffer(); - int offset = 0; - while (offset < buffer.Length) + while (!buffer.IsEmpty) { - int chunkSize = Math.Min(buffer.Length - offset, workBuffer.Length); - ReadOnlySpan span = buffer.Span; + int chunkSize = Math.Min(buffer.Length, workBuffer.Length); + ReadOnlySpan chunk = buffer.Span[..chunkSize]; for (int i = 0; i < chunkSize; i++) { byte ks = DecryptByte(_key2); - byte p = span[offset + i]; + byte p = chunk[i]; workBuffer[i] = (byte)(p ^ ks); UpdateKeys(ref _key0, ref _key1, ref _key2, p); } await _base.WriteAsync(workBuffer.AsMemory(0, chunkSize), cancellationToken).ConfigureAwait(false); - offset += chunkSize; + buffer = buffer[chunkSize..]; } } diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index 1c9c06db480707..ba190c76a8071e 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -39,7 +39,9 @@ public override long Position protected override void Dispose(bool disposing) { if (disposing) + { _baseStream.Dispose(); + } base.Dispose(disposing); } } @@ -639,7 +641,10 @@ public static async Task ReadStreamSeekOps(bool async) { foreach (ZipArchiveEntry e in archive.Entries) { - if (e.Length == 0) continue; // Skip empty entries for this test + if (e.Length == 0) + { + continue; // Skip empty entries for this test + } Stream s = await OpenEntryStream(async, e); @@ -709,7 +714,10 @@ public static async Task ReadEntryContentTwice(bool async, bool useSeekMethod) { foreach (ZipArchiveEntry e in archive.Entries) { - if (e.Length == 0) continue; // Skip empty entries for this test + if (e.Length == 0) + { + continue; // Skip empty entries for this test + } Stream s = await OpenEntryStream(async, e); @@ -932,9 +940,13 @@ public static async Task StrongEncryptionDetectedAsUnknown(bool async) { byte[] data = "hello"u8.ToArray(); if (async) - await entryStream.WriteAsync(data); - else - entryStream.Write(data, 0, data.Length); + { + await entryStream.WriteAsync(data); + } + else + { + entryStream.Write(data, 0, data.Length); + } } await DisposeZipArchive(async, createArchive); @@ -1107,7 +1119,6 @@ public static async Task ReadArchiveCommentAsync_DoesNotCallSyncRead() Assert.Equal(ExpectedComment, readArchive.Comment); await readArchive.DisposeAsync(); } - } } } From 9eb72c75cb910e87da160a6983d9b9e6f4e91089 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 26 Jun 2026 14:06:51 +0300 Subject: [PATCH 76/83] address copilot comments --- .../System/IO/Compression/ZipTestHelper.cs | 2 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 80 +++++++++++-------- .../System/IO/Compression/ZipArchiveEntry.cs | 76 ++++++++++-------- .../tests/WinZipAesStreamConformanceTests.cs | 2 +- .../tests/ZipCryptoStreamConformanceTests.cs | 2 +- .../ZipCryptoStreamWrappedConformanceTests.cs | 2 +- 6 files changed, 92 insertions(+), 72 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index fa80c1156e4b45..44cd155ab795fe 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -14,7 +14,7 @@ public partial class ZipFileTestBase : FileCleanupTestBase { public static string bad(string filename) => Path.Combine("ZipTestData", "badzipfiles", filename); public static string compat(string filename) => Path.Combine("ZipTestData", "compat", filename); - public static string passwordProtected(string filename) => Path.Combine("ZipTestData", "PasswordProtectedZipArchives", filename); + public static string passwordProtected(string filename) => Path.Combine("PasswordProtectedZipArchives", filename); public static string strange(string filename) => Path.Combine("ZipTestData", "StrangeZipFiles", filename); public static string zfile(string filename) => Path.Combine("ZipTestData", "refzipfiles", filename); public static string zfolder(string filename) => Path.Combine("ZipTestData", "refzipfolders", filename); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 0e6fe7b3664abc..046262ac98865e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -799,15 +799,20 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can ZipCompressionMethod savedMethod = CompressionMethod; CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; - var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); - await using (crcStream.ConfigureAwait(false)) + try { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + await using (crcStream.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + } + } + finally + { + // Restore CompressionMethod - AesCompressionMethodValue is used directly when writing headers + CompressionMethod = savedMethod; } - - // Restore CompressionMethod - AesCompressionMethodValue is used directly when writing headers - CompressionMethod = savedMethod; } else { @@ -859,40 +864,45 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can ZipEncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; - // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new - // AES extra field (the original one in _lhUnknownExtraFields will be used). - if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) + try { - CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; - Encryption = ZipEncryptionMethod.None; - } + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) + { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + Encryption = ZipEncryptionMethod.None; + } - await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); + await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); - // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag - // (because Encryption was temporarily set to None and the stream is seekable). - // If the original entry had a data descriptor, patch the general-purpose bit - // flags in the already-written local header to match, so the header on disk - // is consistent with the data descriptor we conditionally write below. - if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && - (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag + // (because Encryption was temporarily set to None and the stream is seekable). + // If the original entry had a data descriptor, patch the general-purpose bit + // flags in the already-written local header to match, so the header on disk + // is consistent with the data descriptor we conditionally write below. + if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && + (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + { + long currentPos = _archive.ArchiveStream.Position; + _archive.ArchiveStream.Seek( + _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, + SeekOrigin.Begin); + byte[] flagBytes = new byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); + await _archive.ArchiveStream.WriteAsync(flagBytes, cancellationToken).ConfigureAwait(false); + _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + } + } + finally { - long currentPos = _archive.ArchiveStream.Position; - _archive.ArchiveStream.Seek( - _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, - SeekOrigin.Begin); - byte[] flagBytes = new byte[2]; - BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); - await _archive.ArchiveStream.WriteAsync(flagBytes, cancellationToken).ConfigureAwait(false); - _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + // Restore original state + _generalPurposeBitFlag = savedFlags; + Encryption = savedEncryption; + CompressionMethod = savedCompressionMethod; } - // Restore original state - _generalPurposeBitFlag = savedFlags; - Encryption = savedEncryption; - CompressionMethod = savedCompressionMethod; - // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { 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 85c69760d50bf8..299b6c202fa63d 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 @@ -1831,13 +1831,18 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) ZipCompressionMethod savedMethod = CompressionMethod; CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; - using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + try { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - _storedUncompressedData.CopyTo(crcStream); + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + } + finally + { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; } - - CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; } else { @@ -1883,40 +1888,45 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) ZipEncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; - // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear _encryptionMethod so WriteLocalFileHeader doesn't create a new - // AES extra field (the original one in _lhUnknownExtraFields will be used). - if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) + try { - CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; - Encryption = ZipEncryptionMethod.None; - } + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeader doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) + { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + Encryption = ZipEncryptionMethod.None; + } - WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); + WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); - // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag - // (because Encryption was temporarily set to None and the stream is seekable). - // If the original entry had a data descriptor, patch the general-purpose bit - // flags in the already-written local header to match, so the header on disk - // is consistent with the data descriptor we conditionally write below. - if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && - (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + // WriteLocalFileHeaderInitialize may have cleared the DataDescriptor flag + // (because Encryption was temporarily set to None and the stream is seekable). + // If the original entry had a data descriptor, patch the general-purpose bit + // flags in the already-written local header to match, so the header on disk + // is consistent with the data descriptor we conditionally write below. + if ((savedFlags & BitFlagValues.DataDescriptor) != 0 && + (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) + { + long currentPos = _archive.ArchiveStream.Position; + _archive.ArchiveStream.Seek( + _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, + SeekOrigin.Begin); + Span flagBytes = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); + _archive.ArchiveStream.Write(flagBytes); + _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + } + } + finally { - long currentPos = _archive.ArchiveStream.Position; - _archive.ArchiveStream.Seek( - _offsetOfLocalHeader + ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags, - SeekOrigin.Begin); - Span flagBytes = stackalloc byte[2]; - BinaryPrimitives.WriteUInt16LittleEndian(flagBytes, (ushort)savedFlags); - _archive.ArchiveStream.Write(flagBytes); - _archive.ArchiveStream.Seek(currentPos, SeekOrigin.Begin); + // Restore original state + _generalPurposeBitFlag = savedFlags; + Encryption = savedEncryption; + CompressionMethod = savedCompressionMethod; } - // Restore original state - _generalPurposeBitFlag = savedFlags; - Encryption = savedEncryption; - CompressionMethod = savedCompressionMethod; - // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 9291abd1990cbc..13277405be72d1 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -34,7 +34,7 @@ public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConforma /// public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConformanceTests { - private const string TestPassword = "test-password"; + private const string TestPassword = "PLACEHOLDER"; private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); private static readonly CreateKeyDelegate s_createKey; diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index b3405643f6c5a1..7488063d030de0 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -14,7 +14,7 @@ namespace System.IO.Compression.Tests [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests { - private const string TestPassword = "test-password"; + private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; private delegate object CreateKeyDelegate(ReadOnlySpan password); diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 95beee145ec014..12a3226b9ba978 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -16,7 +16,7 @@ namespace System.IO.Compression.Tests [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamConformanceTests { - private const string TestPassword = "test-password"; + private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; private delegate object CreateKeyDelegate(ReadOnlySpan password); From 3a827fd2c6ea905c5443c76a3ee34d8fc3be49f4 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 29 Jun 2026 12:08:49 +0300 Subject: [PATCH 77/83] fix failing test --- .../tests/WinZipAesStreamConformanceTests.cs | 2 ++ .../tests/ZipArchive/zip_ReadTests.cs | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index 13277405be72d1..b609f981d32847 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -13,6 +13,7 @@ namespace System.IO.Compression.Tests /// Conformance tests for WinZipAesStream (AES-128). /// [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] [UnsupportedOSPlatform("browser")] public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConformanceTests { @@ -23,6 +24,7 @@ public sealed class WinZipAes128StreamConformanceTests : WinZipAesStreamConforma /// Conformance tests for WinZipAesStream (AES-256). /// [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] [UnsupportedOSPlatform("browser")] public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConformanceTests { diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index ba190c76a8071e..c4d0a73831ae42 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -1088,12 +1088,15 @@ public async Task DecryptEntries_DifferentPasswords(bool async) public async Task PasswordProtectedZip64_UpdateMode_Throws(bool async) { using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtectedZIP64.zip")); + ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Update); - await Assert.ThrowsAsync(async () => - { - ZipArchive archive = await CreateZipArchive(async, archiveStream, ZipArchiveMode.Update); - await DisposeZipArchive(async, archive); - }); + ZipArchiveEntry entry = archive.Entries[0]; + + // The entry reports an uncompressed size larger than Update mode can buffer in memory, + // so opening it for update must throw. + await Assert.ThrowsAsync(async () => await OpenEntryStream(async, entry, "S3cur3P@ssw0rd")); + + await DisposeZipArchive(async, archive); } [Fact] From 2f5458ffb8378c2957afb59ee4cc27b58cdd8da8 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 30 Jun 2026 15:23:54 +0300 Subject: [PATCH 78/83] solve comments --- .../System/IO/Compression/ZipTestHelper.cs | 6 -- .../IO/Compression/ZipFile.Create.Async.cs | 8 +- .../IO/Compression/ZipFile.Extract.Async.cs | 8 +- ...pFileExtensions.ZipArchive.Create.Async.cs | 4 +- .../ZipFileExtensions.ZipArchive.Create.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 4 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 4 +- .../src/Resources/Strings.resx | 2 +- .../IO/Compression/WinZipAesKeyMaterial.cs | 35 ------- .../System/IO/Compression/WinZipAesStream.cs | 92 ++----------------- .../IO/Compression/ZipArchiveEntry.Async.cs | 6 +- .../System/IO/Compression/ZipArchiveEntry.cs | 36 ++++---- .../src/System/IO/Compression/ZipBlocks.cs | 31 +++---- .../System/IO/Compression/ZipCryptoStream.cs | 68 +++++--------- .../tests/ZipArchive/zip_ReadTests.cs | 15 ++- 15 files changed, 90 insertions(+), 231 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index 44cd155ab795fe..b35ef8c140ba8a 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -64,9 +64,7 @@ await stream.ReadAsync(buffer, totalBytesRead, bytesLeftToRead) : stream.Read(buffer, totalBytesRead, bytesLeftToRead); if (bytesRead == 0) throw new IOException("Unexpected end of stream"); - { totalBytesRead += bytesRead; - } bytesLeftToRead -= bytesRead; } } @@ -99,9 +97,7 @@ public static bool ArraysEqual(T[] a, T[] b) where T : IComparable for (int i = 0; i < a.Length; i++) { if (a[i].CompareTo(b[i]) != 0) return false; - { } - } return true; } @@ -110,9 +106,7 @@ public static bool ArraysEqual(T[] a, T[] b, int length) where T : IComparabl for (int i = 0; i < length; i++) { if (a[i].CompareTo(b[i]) != 0) return false; - { } - } return true; } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs index 336d06557ac2e6..5a686bbb2f82fb 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.Async.cs @@ -451,7 +451,7 @@ public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, st (sourceDirectoryName, destinationArchiveFileName) = GetFullPathsForDoCreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); ZipArchive archive = await OpenAsync(destinationArchiveFileName, ZipArchiveMode.Create, options.EntryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password, options.EncryptionMethod, cancellationToken).ConfigureAwait(false); } @@ -478,7 +478,7 @@ public static async Task CreateFromDirectoryAsync(string sourceDirectoryName, St sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, options.CompressionLevel); ZipArchive archive = await ZipArchive.CreateAsync(destination, ZipArchiveMode.Create, leaveOpen: true, options.EntryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, options.CompressionLevel, options.IncludeBaseDirectory, options.Password, options.EncryptionMethod, cancellationToken).ConfigureAwait(false); } @@ -497,7 +497,7 @@ private static async Task DoCreateFromDirectoryAsync(string sourceDirectoryName, // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. ZipArchive archive = await OpenAsync(destinationArchiveFileName, ZipArchiveMode.Create, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -511,7 +511,7 @@ private static async Task DoCreateFromDirectoryAsync(string sourceDirectoryName, sourceDirectoryName = ValidateAndGetFullPathForDoCreateFromDirectory(sourceDirectoryName, destination, compressionLevel); ZipArchive archive = await ZipArchive.CreateAsync(destination, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await CreateZipArchiveFromDirectoryAsync(sourceDirectoryName, archive, compressionLevel, includeBaseDirectory, cancellationToken: cancellationToken).ConfigureAwait(false); } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs index 4c09066df95da9..f7b5e1b823a0c2 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.Async.cs @@ -199,7 +199,7 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s ArgumentNullException.ThrowIfNull(sourceArchiveFileName); ZipArchive archive = await OpenAsync(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); } @@ -268,7 +268,7 @@ private static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, ArgumentNullException.ThrowIfNull(sourceArchiveFileName); ZipArchive archive = await OpenAsync(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { foreach (ZipArchiveEntry entry in archive.Entries) { @@ -430,7 +430,7 @@ public static async Task ExtractToDirectoryAsync(Stream source, string destinati } ZipArchive archive = await ZipArchive.CreateAsync(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { await archive.ExtractToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); } @@ -486,7 +486,7 @@ private static async Task ExtractToDirectoryAsync(Stream source, string destinat } ZipArchive archive = await ZipArchive.CreateAsync(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding, cancellationToken).ConfigureAwait(false); - await using (archive) + await using (archive.ConfigureAwait(false)) { foreach (ZipArchiveEntry entry in archive.Entries) { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs index ce890f32dbc4e3..783e1119df63ac 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.Async.cs @@ -146,11 +146,11 @@ internal static async Task DoCreateEntryFromFileAsync(this ZipA (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password.Span, encryption); - await using (fs) + await using (fs.ConfigureAwait(false)) { Stream es = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using (es) + await using (es.ConfigureAwait(false)) { await fs.CopyToAsync(es, cancellationToken).ConfigureAwait(false); } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 89f998cff8812c..527d67dd587388 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -165,7 +165,7 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip } ZipArchiveEntry entry; - if (!password.IsEmpty && encryption != ZipEncryptionMethod.None) + if (encryption != ZipEncryptionMethod.None) { entry = compressionLevel.HasValue ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 45b6f77e983ec3..00a0b40f4eb76c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -109,7 +109,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string catch { // Clean up the temporary file if extraction failed - if (tempPath is not null && File.Exists(tempPath)) + if (tempPath is not null) { // Ignore exceptions during cleanup; the original exception is more important try { File.Delete(tempPath); } catch { } @@ -168,7 +168,7 @@ private static async Task ExtractToFileAsync(ZipArchiveEntry source, string dest catch { // Clean up the temporary file if extraction failed - if (tempPath is not null && File.Exists(tempPath)) + if (tempPath is not null) { try { File.Delete(tempPath); } catch { } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 296a3f76b119dd..a3f102fd5868c3 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -96,7 +96,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination catch { // Clean up the temporary file if extraction failed - if (tempPath is not null && File.Exists(tempPath)) + if (tempPath is not null) { // Ignore exceptions during cleanup; the original exception is more important try { File.Delete(tempPath); } catch { } @@ -153,7 +153,7 @@ private static void ExtractToFile(ZipArchiveEntry source, string destinationFile catch { // Clean up the temporary file if extraction failed - if (tempPath is not null && File.Exists(tempPath)) + if (tempPath is not null) { try { File.Delete(tempPath); } catch { } } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index b487b822a86e76..19893d7deba8ad 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -424,7 +424,7 @@ Encryption method should not be specified in update mode. - Zip archive encryption is not supported on browser platform. + WinZip AES encryption is not supported on the browser platform. The entry's encryption method is not supported. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 06667c714cabd5..3eafd82852d660 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -33,41 +33,6 @@ private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, SaltSize = GetSaltSize(keySizeBits); } - /// - /// Parses raw key material bytes into their individual components. - /// Validates that the input length matches the expected layout for the given key size. - /// - internal static WinZipAesKeyMaterial Parse(byte[] keyMaterial, int keySizeBits) - { - int saltSize = GetSaltSize(keySizeBits); - int keySizeBytes = keySizeBits / 8; - int expectedSize = checked(saltSize + keySizeBytes + keySizeBytes + 2); - - if (keyMaterial.Length != expectedSize) - { - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - } - - int offset = 0; - - byte[] salt = new byte[saltSize]; - Array.Copy(keyMaterial, offset, salt, 0, saltSize); - offset += saltSize; - - byte[] encryptionKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); - offset += keySizeBytes; - - byte[] hmacKey = new byte[keySizeBytes]; - Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); - offset += keySizeBytes; - - byte[] passwordVerifier = new byte[2]; - Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); - - return new WinZipAesKeyMaterial(salt, encryptionKey, hmacKey, passwordVerifier, keySizeBits); - } - /// /// Derives key material from a password and optional salt using PBKDF2-SHA1. /// diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index fdd5f95f291018..151dcba0b840cb 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -33,9 +33,6 @@ internal sealed class WinZipAesStream : Stream private readonly bool _leaveOpen; private readonly long _encryptedDataSize; private long _encryptedDataRemaining; - private readonly byte[] _partialBlock = new byte[BlockSize]; - private int _partialBlockBytes; - // Pre-generated keystream buffer for efficiency private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation @@ -117,8 +114,8 @@ private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStr throw new InvalidDataException(SR.LocalFileHeaderCorrupt); } - // Verify the password verifier using constant-time comparison to prevent - // timing attacks that could distinguish a wrong password from a corrupt archive. + // Compare the 2-byte password verifier. This is a weak check (only 2 bytes) used to + // fail fast on an obviously wrong password; it is not a security guarantee. if (!CryptographicOperations.FixedTimeEquals(verifier, keyMaterial.PasswordVerifier)) { throw new InvalidDataException(SR.InvalidPassword); @@ -222,10 +219,7 @@ private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) private async Task WriteHeaderAsync(CancellationToken cancellationToken) { - if (_headerWritten) - { - return; - } + Debug.Assert(!_headerWritten); await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); @@ -235,10 +229,7 @@ private async Task WriteHeaderAsync(CancellationToken cancellationToken) private void WriteHeader() { - if (_headerWritten) - { - return; - } + Debug.Assert(!_headerWritten); _baseStream.Write(_salt); _baseStream.Write(_passwordVerifier); @@ -249,9 +240,7 @@ private void ProcessBlock(Span buffer) { Debug.Assert(_hmac is not null, "HMAC should have been initialized"); - int processed = 0; - - while (processed < buffer.Length) + while (!buffer.IsEmpty) { // Ensure we have enough keystream bytes available int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; @@ -262,9 +251,9 @@ private void ProcessBlock(Span buffer) } // Process as many bytes as possible with the available keystream - int bytesToProcess = Math.Min(buffer.Length - processed, keystreamAvailable); + int bytesToProcess = Math.Min(buffer.Length, keystreamAvailable); - Span dataSpan = buffer.Slice(processed, bytesToProcess); + Span dataSpan = buffer.Slice(0, bytesToProcess); ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); if (_encrypting) @@ -281,7 +270,7 @@ private void ProcessBlock(Span buffer) } _keystreamOffset += bytesToProcess; - processed += bytesToProcess; + buffer = buffer.Slice(bytesToProcess); } } @@ -320,7 +309,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel return; } - byte[] authCode = new byte[20]; // SHA1 hash size + byte[] authCode = new byte[SHA1.HashSizeInBytes]; if (!_hmac.TryGetHashAndReset(authCode, out int bytesWritten) || bytesWritten < 10) { throw new CryptographicException(); @@ -454,24 +443,6 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) { - // Fill the partial block buffer if it has data - if (_partialBlockBytes > 0) - { - int copyCount = Math.Min(BlockSize - _partialBlockBytes, buffer.Length); - buffer[..copyCount].CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); - - _partialBlockBytes += copyCount; - buffer = buffer[copyCount..]; - - // If full, encrypt and write immediately - if (_partialBlockBytes == BlockSize) - { - ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); - _baseStream.Write(_partialBlock, 0, BlockSize); - _partialBlockBytes = 0; - } - } - while (!buffer.IsEmpty) { int bytesToProcess = Math.Min(buffer.Length, workBuffer.Length); @@ -528,24 +499,6 @@ private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, Cancellation byte[] workBuffer = GetWriteWorkBuffer(); - // Fill the partial block buffer if it has data - if (_partialBlockBytes > 0) - { - int copyCount = Math.Min(BlockSize - _partialBlockBytes, buffer.Length); - buffer[..copyCount].CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); - - _partialBlockBytes += copyCount; - buffer = buffer[copyCount..]; - - // If full, encrypt and write immediately - if (_partialBlockBytes == BlockSize) - { - ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); - await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BlockSize), cancellationToken).ConfigureAwait(false); - _partialBlockBytes = 0; - } - } - while (!buffer.IsEmpty) { int bytesToProcess = Math.Min(buffer.Length, workBuffer.Length); @@ -563,27 +516,6 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo return WriteAsyncCore(buffer, cancellationToken); } - private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) - { - // Process any bytes remaining in the partial buffer - if (_partialBlockBytes > 0) - { - // Encrypt the partial block (ProcessBlock handles partials by XORing only available bytes) - ProcessBlock(_partialBlock.AsSpan(0, _partialBlockBytes)); - - if (isAsync) - { - await _baseStream.WriteAsync(_partialBlock.AsMemory(0, _partialBlockBytes), cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.Write(_partialBlock, 0, _partialBlockBytes); - } - - _partialBlockBytes = 0; - } - } - protected override void Dispose(bool disposing) { if (_disposed) @@ -642,12 +574,11 @@ public override async ValueTask DisposeAsync() } _disposed = true; - GC.SuppressFinalize(this); } /// /// Completes the encryption sequence: ensures the header is written (even for empty entries), - /// flushes any remaining partial block, appends the HMAC authentication code, and flushes the base stream. + /// appends the HMAC authentication code, and flushes the base stream. /// private async Task FinishEncryptingAsync(bool isAsync, CancellationToken cancellationToken) { @@ -666,9 +597,6 @@ private async Task FinishEncryptingAsync(bool isAsync, CancellationToken cancell } } - // Encrypt remaining partial data - await FinalizeEncryptionAsync(isAsync, cancellationToken).ConfigureAwait(false); - // Write Auth Code await WriteAuthCodeCoreAsync(isAsync, cancellationToken).ConfigureAwait(false); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 046262ac98865e..b5bad16480987c 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -404,7 +404,7 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Write WinZip AES extra field AFTER Zip64 (matching sync version order) // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader - if (UseAesEncryption()) + if (UseAesEncryption) { await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -677,7 +677,7 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Write WinZip AES extra field if using AES encryption // Must match the exact check used in the sync version WriteLocalFileHeader - if (UseAesEncryption()) + if (UseAesEncryption) { await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -758,7 +758,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; } - else if (UseAesEncryption() && _derivedAesKeyMaterial != null) + else if (UseAesEncryption && _derivedAesKeyMaterial != null) { if (OperatingSystem.IsBrowser()) 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 299b6c202fa63d..70fa034c27c2ac 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 @@ -776,7 +776,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 WinZipAesExtraField? aesExtraField = null; int aesExtraFieldSize = 0; - if (UseAesEncryption()) + if (UseAesEncryption) { aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; @@ -784,7 +784,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 // determine if we can fit zip64 extra field and original extra fields all in // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one - int currExtraFieldDataLength = UseAesEncryption() + int currExtraFieldDataLength = UseAesEncryption ? ZipGenericExtraField.TotalSizeExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) : ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) @@ -845,12 +845,12 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u // For AES encryption, write compression method 99 (Aes) in the header // _headerCompressionMethod preserves the original value from the central directory - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)WinZipAesMethod : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption ? (ushort)WinZipAesMethod : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); @@ -878,7 +878,7 @@ internal unsafe void WriteCentralDirectoryFileHeader(bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (UseAesEncryption()) + if (UseAesEncryption) { CreateAesExtraField().WriteBlock(_archive.ArchiveStream); @@ -1032,10 +1032,8 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted => (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != WinZipAesMethod; - private bool UseAesEncryption() - { - return Encryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256; - } + private bool UseAesEncryption => Encryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256; + private bool IsAesEncrypted => (ushort)_headerCompressionMethod == WinZipAesMethod; private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) @@ -1324,7 +1322,7 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); Encryption = ZipEncryptionMethod.ZipCrypto; } - else if (UseAesEncryption()) + else if (UseAesEncryption) { if (OperatingSystem.IsBrowser()) { @@ -1592,7 +1590,7 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; } - else if (UseAesEncryption()) + else if (UseAesEncryption) { _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; @@ -1641,7 +1639,7 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW // Calculate extra field // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one - int currExtraFieldDataLength = UseAesEncryption() + int currExtraFieldDataLength = UseAesEncryption ? ZipGenericExtraField.TotalSizeExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) : ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) @@ -1661,7 +1659,7 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW crc32ToWrite = _crc32; // For AE-2, CRC is always 0 in the local file header - if (UseAesEncryption()) + if (UseAesEncryption) { crc32ToWrite = 0; } @@ -1710,7 +1708,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint crc32, BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); // For AES encryption, write compression method 99 (Aes) in the header - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)WinZipAesMethod : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption ? (ushort)WinZipAesMethod : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); @@ -1737,7 +1735,7 @@ private unsafe bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite, bool zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (UseAesEncryption()) + if (UseAesEncryption) { CreateAesExtraField().WriteBlock(_archive.ArchiveStream); @@ -1806,7 +1804,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _storedUncompressedData.Dispose(); _storedUncompressedData = null; } - else if (UseAesEncryption() && _derivedAesKeyMaterial is not null) + else if (UseAesEncryption && _derivedAesKeyMaterial is not null) { if (OperatingSystem.IsBrowser()) { @@ -2060,7 +2058,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); @@ -2089,7 +2087,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) 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 1aec902aeb653a..2a7a00e8583095 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 @@ -708,17 +708,14 @@ public static unsafe bool TrySkipBlock(Stream stream) bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - } + internal struct WinZipAesExtraField { public const ushort HeaderId = 0x9901; private const int DataSize = 7; // Vendor version (2) + Vendor ID (2) + AES strength (1) + Compression method (2) private const byte VendorIdByte0 = (byte)'A'; private const byte VendorIdByte1 = (byte)'E'; - private ushort _vendorVersion = 2; - private byte _aesStrength; - private ushort _compressionMethod; public WinZipAesExtraField(ushort vendorVersion, byte aesStrength, ushort compressionMethod) { @@ -727,11 +724,11 @@ public WinZipAesExtraField(ushort vendorVersion, byte aesStrength, ushort compre CompressionMethod = compressionMethod; } - public ushort VendorVersion { get => _vendorVersion; set => _vendorVersion = value; } - public byte AesStrength { get => _aesStrength; set => _aesStrength = value; } // 1=128bit, 2=192bit, 3=256bit - public ushort CompressionMethod { get => _compressionMethod; set => _compressionMethod = value; } // Original compression method + public ushort VendorVersion { get; set; } = 2; + public byte AesStrength { get; set; } // 1=128bit, 2=192bit, 3=256bit + public ushort CompressionMethod { get; set; } // Original compression method - public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + public const int TotalSize = 11; // 2 (header) + 2 (size) + 7 (data) /// /// Tries to find and parse the WinZip AES extra field (0x9901) from a list of generic extra fields. @@ -770,25 +767,25 @@ public static bool TryGetFromExtraFields(List? extraFields public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData, out WinZipAesExtraField aesExtraField) { aesExtraField = default; - int offset = 0; - while (offset + 4 <= extraFieldData.Length) // Need at least 4 bytes for header ID and size + while (extraFieldData.Length >= 4) // Need at least 4 bytes for header ID and size { - ushort headerId = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset, 2)); - ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset + 2, 2)); + ushort headerId = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData); + ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(2)); + extraFieldData = extraFieldData.Slice(4); - if (offset + 4 + fieldSize > extraFieldData.Length) + if (fieldSize > extraFieldData.Length) { break; // Not enough data for this field } if (headerId == HeaderId && fieldSize >= DataSize && - TryParseData(extraFieldData.Slice(offset + 4, fieldSize), out aesExtraField)) + TryParseData(extraFieldData.Slice(0, fieldSize), out aesExtraField)) { return true; } - offset += 4 + fieldSize; + extraFieldData = extraFieldData.Slice(fieldSize); } return false; @@ -805,7 +802,7 @@ private static bool TryParseData(ReadOnlySpan data, out WinZipAesExtraFiel { aesExtraField = default; - ushort vendorVersion = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)); + ushort vendorVersion = BinaryPrimitives.ReadUInt16LittleEndian(data); byte aesStrength = data[4]; // Validate vendor ID must be "AE", vendor version must be 1 or 2, @@ -847,7 +844,7 @@ private void WriteBlockCore(Span buffer) buffer[6] = (byte)'A'; buffer[7] = (byte)'E'; buffer[8] = AesStrength; - BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(9), CompressionMethod); } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index ac76c768f34f14..0f968b30ca437d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,19 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Diagnostics; -using System.Text; namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream { - internal const int KeySize = 12; // 3 * sizeof(uint) - private const int EncryptionBufferSize = 4096; private readonly bool _encrypting; @@ -118,37 +117,29 @@ private ZipCryptoStream(Stream baseStream, // Creates the persisted key material from a password. // Returns a struct of 3 integers to keep the key off the heap. - internal static unsafe ZipCryptoKeys CreateKey(ReadOnlySpan password) + internal static ZipCryptoKeys CreateKey(ReadOnlySpan password) { // Initialize keys with standard ZipCrypto initial values uint key0 = 305419896; uint key1 = 591751049; uint key2 = 878082192; - // ASCII produces exactly 1 byte per char, so SegmentSize bytes is sufficient - // for SegmentSize chars. - const int SegmentSize = 32; - Span buf = stackalloc byte[SegmentSize]; - - ReadOnlySpan pwSpan = password; - - while (!pwSpan.IsEmpty) + // Feed the password's UTF-8 bytes into the key schedule. UTF-8 (rather than ASCII) + // preserves non-ASCII passwords; interop with tools that assume a different code page + // is documented as a caveat. + byte[] passwordBytes = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(password.Length)); + try { - ReadOnlySpan segment = pwSpan; - - if (segment.Length > SegmentSize) - { - segment = segment.Slice(0, SegmentSize); - } - - int byteCount = Encoding.ASCII.GetBytes(segment, buf); - - foreach (byte b in buf.Slice(0, byteCount)) + int byteCount = Encoding.UTF8.GetBytes(password, passwordBytes); + for (int i = 0; i < byteCount; i++) { - UpdateKeys(ref key0, ref key1, ref key2, b); + UpdateKeys(ref key0, ref key1, ref key2, passwordBytes[i]); } - - pwSpan = pwSpan.Slice(segment.Length); + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + ArrayPool.Shared.Return(passwordBytes); } return new ZipCryptoKeys(key0, key1, key2); @@ -156,10 +147,7 @@ internal static unsafe ZipCryptoKeys CreateKey(ReadOnlySpan password) private void CalculateHeader(Span header) { - if (header.Length < 12) - { - throw new ArgumentException("Header must be at least 12 bytes.", nameof(header)); - } + Debug.Assert(header.Length == 12); // bytes 0..9 random RandomNumberGenerator.Fill(header.Slice(0, 10)); @@ -176,7 +164,7 @@ private void CalculateHeader(Span header) } // encrypt in place - for (int i = 0; i < 12; i++) + for (int i = 0; i < header.Length; i++) { byte p = header[i]; byte ks = DecryptByte(_key2); @@ -187,7 +175,7 @@ private void CalculateHeader(Span header) } } - private unsafe void WriteHeader() + private unsafe void EnsureHeader() { if (!_encrypting || _headerWritten) { @@ -200,7 +188,7 @@ private unsafe void WriteHeader() _headerWritten = true; } - private async ValueTask WriteHeaderAsync(CancellationToken cancellationToken) + private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) { if (!_encrypting || _headerWritten) { @@ -213,16 +201,6 @@ private async ValueTask WriteHeaderAsync(CancellationToken cancellationToken) _headerWritten = true; } - private void EnsureHeader() - { - WriteHeader(); - } - - private ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) - { - return WriteHeaderAsync(cancellationToken); - } - private static async Task<(uint key0, uint key1, uint key2)> ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, ZipCryptoKeys keys, byte expectedCheckByte, CancellationToken cancellationToken) { // Initialize keys from input @@ -370,7 +348,7 @@ protected override void Dispose(bool disposing) if (disposing) { // If encrypted empty entry (no payload written), still must emit 12-byte header: - if (_encrypting && !_headerWritten) + if (_encrypting) { EnsureHeader(); } @@ -392,7 +370,7 @@ public override async ValueTask DisposeAsync() _disposed = true; // If encrypted empty entry (no payload written), still must emit 12-byte header: - if (_encrypting && !_headerWritten) + if (_encrypting) { await EnsureHeaderAsync(CancellationToken.None).ConfigureAwait(false); } diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index c4d0a73831ae42..184fcaffb75f36 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -940,13 +940,13 @@ public static async Task StrongEncryptionDetectedAsUnknown(bool async) { byte[] data = "hello"u8.ToArray(); if (async) - { - await entryStream.WriteAsync(data); - } - else - { - entryStream.Write(data, 0, data.Length); - } + { + await entryStream.WriteAsync(data); + } + else + { + entryStream.Write(data, 0, data.Length); + } } await DisposeZipArchive(async, createArchive); @@ -1124,4 +1124,3 @@ public static async Task ReadArchiveCommentAsync_DoesNotCallSyncRead() } } } - From 3174d4606d909d867732b24d9d951b2955051a75 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 1 Jul 2026 10:36:04 +0300 Subject: [PATCH 79/83] fix wasm/wasi tests --- .../tests/ZipCryptoStreamConformanceTests.cs | 7 ++++++- .../tests/ZipCryptoStreamWrappedConformanceTests.cs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index 7488063d030de0..aa2b9787e79267 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -11,9 +11,14 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for ZipCryptoStream. /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [ConditionalClass(typeof(ZipCryptoStreamConformanceTests), nameof(IsSupported))] public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests { + // Reflection emit (DynamicMethod) is unsupported under NativeAOT, and the stream conformance + // tests exercise concurrent blocking read/write, which requires multithreading (not available + // on single-threaded wasm or wasi). + public static bool IsSupported => PlatformDetection.IsNotNativeAot && PlatformDetection.IsMultithreadingSupported; + private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 12a3226b9ba978..6dd902c0fbfbea 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -13,9 +13,14 @@ namespace System.IO.Compression.Tests /// Wrapped connected stream conformance tests for ZipCryptoStream. /// Tests encryption → decryption data flow through connected streams. /// - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [ConditionalClass(typeof(ZipCryptoStreamWrappedConformanceTests), nameof(IsSupported))] public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamConformanceTests { + // Reflection emit (DynamicMethod) is unsupported under NativeAOT, and the connected-stream + // conformance tests exercise concurrent blocking read/write, which requires multithreading + // (not available on single-threaded wasm or wasi). + public static bool IsSupported => PlatformDetection.IsNotNativeAot && PlatformDetection.IsMultithreadingSupported; + private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; From d77b9af965d77104a157e4c407685f411f4d4ac6 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 3 Jul 2026 11:23:02 +0300 Subject: [PATCH 80/83] address comments --- .../ZipFileExtensions.ZipArchive.Create.cs | 1 - .../IO/Compression/WinZipAesKeyMaterial.cs | 9 ++- .../System/IO/Compression/WinZipAesStream.cs | 57 +++++++++++++++---- .../IO/Compression/ZipArchiveEntry.Async.cs | 9 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 57 ++++++------------- .../src/System/IO/Compression/ZipBlocks.cs | 1 - .../System/IO/Compression/ZipCryptoStream.cs | 21 ++++--- .../tests/ZipCryptoStreamConformanceTests.cs | 8 +-- .../ZipCryptoStreamWrappedConformanceTests.cs | 8 +-- 9 files changed, 95 insertions(+), 76 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 527d67dd587388..5a492f3c200ddd 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -91,7 +91,6 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, ReadOnlySpan password, ZipEncryptionMethod encryption) => DoCreateEntryFromFile(destination, sourceFileName, entryName, null, password, encryption); - /// ///

Adds a file from the file system to the archive under the specified entry name with encryption. /// The new entry in the archive will contain the contents of the file. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs index 3eafd82852d660..95fea76f3ec5f1 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -13,7 +13,6 @@ namespace System.IO.Compression /// Represents the parsed components of WinZip AES key material. /// The key material layout is: [salt][encryption key][HMAC key][password verifier (2 bytes)]. ///

- [UnsupportedOSPlatform("browser")] internal readonly struct WinZipAesKeyMaterial { public byte[] Salt { get; } @@ -25,6 +24,10 @@ internal readonly struct WinZipAesKeyMaterial private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, byte[] passwordVerifier, int keySizeBits) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } Salt = salt; EncryptionKey = encryptionKey; HmacKey = hmacKey; @@ -38,6 +41,10 @@ private WinZipAesKeyMaterial(byte[] salt, byte[] encryptionKey, byte[] hmacKey, /// internal static unsafe WinZipAesKeyMaterial Create(ReadOnlySpan password, byte[]? salt, int keySizeBits) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } int saltSize = GetSaltSize(keySizeBits); int keySizeBytes = keySizeBits / 8; int totalKeySize = checked(keySizeBytes + keySizeBytes + 2); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 151dcba0b840cb..406a89b9cfd556 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -11,7 +11,6 @@ namespace System.IO.Compression { - [UnsupportedOSPlatform("browser")] internal sealed class WinZipAesStream : Stream { private const int BlockSize = 16; // AES block size in bytes @@ -40,19 +39,36 @@ internal sealed class WinZipAesStream : Stream // Reusable work buffer for write operations, lazily allocated on first write private byte[]? _writeWorkBuffer; - internal static int GetSaltSize(int keySizeBits) => WinZipAesKeyMaterial.GetSaltSize(keySizeBits); + internal static int GetSaltSize(int keySizeBits) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + return WinZipAesKeyMaterial.GetSaltSize(keySizeBits); + } /// /// Derives key material from a password and optional salt. /// internal static WinZipAesKeyMaterial CreateKey(ReadOnlySpan password, byte[]? salt, int keySizeBits) - => WinZipAesKeyMaterial.Create(password, salt, keySizeBits); + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + return WinZipAesKeyMaterial.Create(password, salt, keySizeBits); + } /// /// Creates a WinZipAesStream synchronously. Reads and validates the header for decryption. /// internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } ArgumentNullException.ThrowIfNull(baseStream); if (!encrypting) @@ -68,6 +84,11 @@ internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial k /// internal static async Task CreateAsync(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, CancellationToken cancellationToken = default) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + ArgumentNullException.ThrowIfNull(baseStream); if (!encrypting) @@ -83,6 +104,10 @@ internal static async Task CreateAsync(Stream baseStream, WinZi /// private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, WinZipAesKeyMaterial keyMaterial, CancellationToken cancellationToken) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } int saltSize = keyMaterial.SaltSize; // Read salt from stream @@ -128,6 +153,11 @@ private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStr /// private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen) { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + _baseStream = baseStream; Debug.Assert((totalStreamSize >= 0) == !encrypting, "Total stream size must be known when decrypting"); @@ -137,8 +167,6 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon _leaveOpen = leaveOpen; _aes = Aes.Create(); - _aes.Mode = CipherMode.ECB; - _aes.Padding = PaddingMode.None; _salt = keyMaterial.Salt; _passwordVerifier = keyMaterial.PasswordVerifier; @@ -166,6 +194,8 @@ private WinZipAesStream(Stream baseStream, WinZipAesKeyMaterial keyMaterial, lon _aes.SetKey(keyMaterial.EncryptionKey); } + // Compute and check the HMAC for the entire stream. This is called at the end of the stream, after all data has been read/written, + // similarly to how CRC is computed for non-encrypted ZIP entries. The HMAC is stored in the last 10 bytes of the stream. private unsafe void FinalizeAndCompareHMAC(byte[] storedAuth) { @@ -178,8 +208,9 @@ private unsafe void FinalizeAndCompareHMAC(byte[] storedAuth) throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } - // Compare the first 10 bytes of the expected hash - if (!CryptographicOperations.FixedTimeEquals(storedAuth, expectedAuth.Slice(0, 10))) + // Compare the 10 bytes of the expected hash + Debug.Assert(storedAuth.Length == 10); + if (!CryptographicOperations.FixedTimeEquals(storedAuth, expectedAuth.Slice(0, storedAuth.Length))) { throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } @@ -309,21 +340,23 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel return; } + // WinZip AES spec requires only the first 10 bytes of the HMAC + const int MacSizeInBytes = 10; + byte[] authCode = new byte[SHA1.HashSizeInBytes]; - if (!_hmac.TryGetHashAndReset(authCode, out int bytesWritten) || bytesWritten < 10) + + if (!_hmac.TryGetHashAndReset(authCode, out int bytesWritten) || bytesWritten < MacSizeInBytes) { throw new CryptographicException(); } - - // WinZip AES spec requires only the first 10 bytes of the HMAC if (isAsync) { // WriteAsync requires Memory, so we must copy to a heap buffer for the async path - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(authCode.AsMemory(0, MacSizeInBytes), cancellationToken).ConfigureAwait(false); } else { - _baseStream.Write(authCode.AsSpan(0, 10)); + _baseStream.Write(authCode.AsSpan(0, MacSizeInBytes)); } _authCodeFinalized = true; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index b5bad16480987c..6a74dc6f308525 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -728,7 +728,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - var encryptionStream = ZipCryptoStream.Create( + ZipCryptoStream encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, keys: _derivedZipCryptoKeyMaterial.Value, passwordVerifierLow2Bytes: verifierLow2Bytes, @@ -738,7 +738,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await using (encryptionStream.ConfigureAwait(false)) { // Use GetDataCompressor which handles CRC calculation and compression - var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + CheckSumAndSizeWriteStream crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); await using (crcStream.ConfigureAwait(false)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); @@ -782,14 +782,13 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // The AES extra field stores the real compression method bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; - var encryptionStream = WinZipAesStream.Create( + WinZipAesStream encryptionStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, keyMaterial: _derivedAesKeyMaterial.Value, totalStreamSize: -1, encrypting: true, leaveOpen: true); - await using (encryptionStream.ConfigureAwait(false)) { // Only compress/write if there's data @@ -801,7 +800,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can try { - var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + CheckSumAndSizeWriteStream crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); await using (crcStream.ConfigureAwait(false)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); 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 70fa034c27c2ac..f67565a2342e64 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 @@ -514,7 +514,7 @@ public Stream Open(FileAccess access, ReadOnlySpan password) return OpenCore(access, password); } - private FileAccess InferAccessFromMode()=> _archive.Mode switch + private FileAccess InferAccessFromMode() => _archive.Mode switch { ZipArchiveMode.Read => FileAccess.Read, ZipArchiveMode.Create => FileAccess.Write, @@ -1038,12 +1038,14 @@ private byte CalculateZipCryptoCheckByte() private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) { + // Get number of bits for AES key size based on the encryption method + // Only possible values are: AES-128 = 128 bits, AES-192 = 192 bits, AES-256 as per the specs return encryption switch { ZipEncryptionMethod.Aes128 => 128, ZipEncryptionMethod.Aes192 => 192, ZipEncryptionMethod.Aes256 => 256, - _ => 256 // Default to AES-256 + _ => throw new InvalidDataException(SR.InvalidAesStrength) }; } @@ -1065,11 +1067,6 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlySpan< if (IsAesEncrypted) { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } - if (_aesSalt is null) { throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -1165,6 +1162,7 @@ private Stream BuildDecompressionPipeline(Stream streamToDecompress) return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); } + private WrappedStream OpenInWriteMode() { if (_everOpenedForWrite) @@ -1208,11 +1206,6 @@ private WrappedStream OpenInWriteModeCore() } else if (encryptionMethod is ZipEncryptionMethod.Aes256 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes128) { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } - WinZipAesKeyMaterial keyMaterial = _derivedAesKeyMaterial ?? throw new InvalidOperationException(SR.EmptyPassword); @@ -1324,10 +1317,6 @@ private void SetupEncryptionKeyMaterial(ReadOnlySpan password) } else if (UseAesEncryption) { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } // Generate new salt and derive key material for AES // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(Encryption); @@ -1347,27 +1336,23 @@ internal void PrepareEncryption(ReadOnlySpan password, ZipEncryptionMethod throw new ArgumentException(SR.EmptyPassword, nameof(password)); } - if (encryptionMethod is ZipEncryptionMethod.None or ZipEncryptionMethod.Unknown) - { - throw new ArgumentOutOfRangeException(nameof(encryptionMethod), SR.EncryptionNotSpecified); - } - - Encryption = encryptionMethod; - if (encryptionMethod == ZipEncryptionMethod.ZipCrypto) { + Encryption = encryptionMethod; _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); } else if (encryptionMethod is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } + Encryption = encryptionMethod; int keySizeBits = GetAesKeySizeBits(encryptionMethod); _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); } + else + { + // Covers ZipEncryptionMethod.None, ZipEncryptionMethod.Unknown, and any undefined/out-of-range value. + throw new ArgumentOutOfRangeException(nameof(encryptionMethod), encryptionMethod, SR.EncryptionNotSpecified); + } } /// @@ -1383,7 +1368,7 @@ private WinZipAesExtraField CreateAesExtraField() ZipEncryptionMethod.Aes128 => (byte)1, ZipEncryptionMethod.Aes192 => (byte)2, ZipEncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 // Default to AES-256 + _ => throw new InvalidDataException(SR.InvalidAesStrength) }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)ZipCompressionMethod.Stored @@ -1782,7 +1767,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - using (var encryptionStream = ZipCryptoStream.Create( + using (ZipCryptoStream encryptionStream = ZipCryptoStream.Create( baseStream: _archive.ArchiveStream, keys: _derivedZipCryptoKeyMaterial.Value, passwordVerifierLow2Bytes: verifierLow2Bytes, @@ -1790,7 +1775,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) crc32: null, leaveOpen: true)) { - using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + using (CheckSumAndSizeWriteStream crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); _storedUncompressedData.CopyTo(crcStream); @@ -1806,10 +1791,6 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) } else if (UseAesEncryption && _derivedAesKeyMaterial is not null) { - if (OperatingSystem.IsBrowser()) - { - throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); - } bool usedZip64InLH = WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); @@ -1817,7 +1798,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; - using (var encryptionStream = WinZipAesStream.Create( + using (WinZipAesStream encryptionStream = WinZipAesStream.Create( baseStream: _archive.ArchiveStream, keyMaterial: _derivedAesKeyMaterial.Value, totalStreamSize: -1, @@ -1831,7 +1812,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) try { - using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + using (CheckSumAndSizeWriteStream crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); _storedUncompressedData.CopyTo(crcStream); @@ -1859,9 +1840,7 @@ private unsafe void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) else { // Non-encrypted: use standard path - using (DirectToArchiveWriterStream entryWriter = new( - GetDataCompressor(_archive.ArchiveStream, true, null, null), - this)) + using (DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); _storedUncompressedData.CopyTo(entryWriter); 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 2a7a00e8583095..a7709b8e98df69 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 @@ -848,7 +848,6 @@ private void WriteBlockCore(Span buffer) } } - internal sealed partial class ZipCentralDirectoryFileHeader { // The Zip File Format Specification references 0x02014B50, this is a big endian representation. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 0f968b30ca437d..3243c56566ebb0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -209,18 +209,19 @@ private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) uint key2 = keys.Key2; byte[] hdr = new byte[12]; - int bytesRead; - if (isAsync) - { - bytesRead = await baseStream.ReadAtLeastAsync(hdr, hdr.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); - } - else + try { - bytesRead = baseStream.ReadAtLeast(hdr, hdr.Length, throwOnEndOfStream: false); + if (isAsync) + { + await baseStream.ReadExactlyAsync(hdr, cancellationToken).ConfigureAwait(false); + } + else + { + baseStream.ReadExactly(hdr); + } } - - if (bytesRead < hdr.Length) + catch (EndOfStreamException) { throw new InvalidDataException(SR.TruncatedZipCryptoHeader); } @@ -404,7 +405,9 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation Span span = buffer.Span; for (int i = 0; i < n; i++) + { span[i] = DecryptAndUpdateKeys(span[i]); + } return n; } diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index aa2b9787e79267..fd642f087dfaf8 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -14,10 +14,10 @@ namespace System.IO.Compression.Tests [ConditionalClass(typeof(ZipCryptoStreamConformanceTests), nameof(IsSupported))] public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests { - // Reflection emit (DynamicMethod) is unsupported under NativeAOT, and the stream conformance - // tests exercise concurrent blocking read/write, which requires multithreading (not available - // on single-threaded wasm or wasi). - public static bool IsSupported => PlatformDetection.IsNotNativeAot && PlatformDetection.IsMultithreadingSupported; + // Reflection emit (DynamicMethod) is unsupported on platforms without dynamic code generation + // (e.g. NativeAOT and Mono AOT), and the stream conformance tests exercise concurrent blocking + // read/write, which requires multithreading (not available on single-threaded wasm or wasi). + public static bool IsSupported => PlatformDetection.IsReflectionEmitSupported && PlatformDetection.IsMultithreadingSupported; private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 6dd902c0fbfbea..026a9ba8ca21b0 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -16,10 +16,10 @@ namespace System.IO.Compression.Tests [ConditionalClass(typeof(ZipCryptoStreamWrappedConformanceTests), nameof(IsSupported))] public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamConformanceTests { - // Reflection emit (DynamicMethod) is unsupported under NativeAOT, and the connected-stream - // conformance tests exercise concurrent blocking read/write, which requires multithreading - // (not available on single-threaded wasm or wasi). - public static bool IsSupported => PlatformDetection.IsNotNativeAot && PlatformDetection.IsMultithreadingSupported; + // Reflection emit (DynamicMethod) is unsupported on platforms without dynamic code generation + // (e.g. NativeAOT and Mono AOT), and the connected-stream conformance tests exercise concurrent + // blocking read/write, which requires multithreading (not available on single-threaded wasm or wasi). + public static bool IsSupported => PlatformDetection.IsReflectionEmitSupported && PlatformDetection.IsMultithreadingSupported; private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; From 6e036abb1f4d9d2f79f6ee896c32061806a0c83e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 7 Jul 2026 09:47:07 +0200 Subject: [PATCH 81/83] fix wasm failure --- .../tests/ZipCryptoStreamConformanceTests.cs | 18 ++++++++++++------ .../ZipCryptoStreamWrappedConformanceTests.cs | 7 +------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs index fd642f087dfaf8..3d89614d329d84 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -11,14 +11,9 @@ namespace System.IO.Compression.Tests /// /// Conformance tests for ZipCryptoStream. /// - [ConditionalClass(typeof(ZipCryptoStreamConformanceTests), nameof(IsSupported))] + [ConditionalClass(typeof(ZipCryptoStreamTestsSupport), nameof(ZipCryptoStreamTestsSupport.IsSupported))] public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests { - // Reflection emit (DynamicMethod) is unsupported on platforms without dynamic code generation - // (e.g. NativeAOT and Mono AOT), and the stream conformance tests exercise concurrent blocking - // read/write, which requires multithreading (not available on single-threaded wasm or wasi). - public static bool IsSupported => PlatformDetection.IsReflectionEmitSupported && PlatformDetection.IsMultithreadingSupported; - private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; @@ -121,4 +116,15 @@ static ZipCryptoStreamConformanceTests() return Task.FromResult(decryptStream); } } + + // The ZipCryptoStream conformance test classes have a static constructor that uses DynamicMethod to + // invoke internal members. Accessing any static member of those classes (including an IsSupported gate + // property) would trigger that static constructor, which throws PlatformNotSupportedException on + // platforms without dynamic code generation (NativeAOT, Mono AOT). Hosting the gate on this separate + // type ensures the ConditionalClass check can be evaluated without initializing the test classes, so + // the tests are correctly skipped instead of failing during type initialization. + internal static class ZipCryptoStreamTestsSupport + { + public static bool IsSupported => PlatformDetection.IsReflectionEmitSupported && PlatformDetection.IsMultithreadingSupported; + } } diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs index 026a9ba8ca21b0..a32015c4b6d759 100644 --- a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -13,14 +13,9 @@ namespace System.IO.Compression.Tests /// Wrapped connected stream conformance tests for ZipCryptoStream. /// Tests encryption → decryption data flow through connected streams. /// - [ConditionalClass(typeof(ZipCryptoStreamWrappedConformanceTests), nameof(IsSupported))] + [ConditionalClass(typeof(ZipCryptoStreamTestsSupport), nameof(ZipCryptoStreamTestsSupport.IsSupported))] public class ZipCryptoStreamWrappedConformanceTests : WrappingConnectedStreamConformanceTests { - // Reflection emit (DynamicMethod) is unsupported on platforms without dynamic code generation - // (e.g. NativeAOT and Mono AOT), and the connected-stream conformance tests exercise concurrent - // blocking read/write, which requires multithreading (not available on single-threaded wasm or wasi). - public static bool IsSupported => PlatformDetection.IsReflectionEmitSupported && PlatformDetection.IsMultithreadingSupported; - private const string TestPassword = "PLACEHOLDER"; private const ushort PasswordVerifier = 0x1234; From 4e1d41acfbaf44c2ee8c2799d4062520e20b0c16 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 7 Jul 2026 13:31:53 +0200 Subject: [PATCH 82/83] add guards for unsupported tests --- .../src/System/IO/Compression/WinZipAesStream.cs | 1 + .../System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 406a89b9cfd556..8efe983efa0e50 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -69,6 +69,7 @@ internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial k { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } + ArgumentNullException.ThrowIfNull(baseStream); if (!encrypting) diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs index 184fcaffb75f36..8ab7802ff61ac5 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs @@ -981,6 +981,7 @@ public static async Task StrongEncryptionDetectedAsUnknown(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] public async Task DecryptEntries_SamePassword_7Zip(bool async) { string password = "S3cur3P@ssw0rd"; @@ -1016,6 +1017,8 @@ public async Task DecryptEntries_SamePassword_7Zip(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] + public async Task DecryptEntries_MixedEncryptions(bool async) { string password = "S3cur3P@ssw0rd"; @@ -1051,6 +1054,7 @@ public async Task DecryptEntries_MixedEncryptions(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] public async Task DecryptEntries_DifferentPasswords(bool async) { using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_DifferentPasswords.zip")); @@ -1085,6 +1089,7 @@ public async Task DecryptEntries_DifferentPasswords(bool async) [Theory] [MemberData(nameof(Get_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on browser.")] public async Task PasswordProtectedZip64_UpdateMode_Throws(bool async) { using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtectedZIP64.zip")); From 2b3e067b8534ac13797037237054453bbcd5133b Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:34:44 +0200 Subject: [PATCH 83/83] Update src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs Co-authored-by: Jeremy Barton --- .../src/System/IO/Compression/WinZipAesStream.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 8efe983efa0e50..cb5a365bbe3e7d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -45,6 +45,7 @@ internal static int GetSaltSize(int keySizeBits) { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } + return WinZipAesKeyMaterial.GetSaltSize(keySizeBits); }