diff --git a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml index 85c92f292323f9..ff5db8cf02512c 100644 --- a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml +++ b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml @@ -200,6 +200,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' @@ -207,4 +215,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/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/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/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs new file mode 100644 index 00000000000000..9c66cfc64576f2 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -0,0 +1,179 @@ +// 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; + + // 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); + + 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; + + static WinZipAesStreamFuzzer() + { + 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, + 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(); + } + + // 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. + } + 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 new file mode 100644 index 00000000000000..0765bd6c680525 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -0,0 +1,135 @@ +// 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(); + } + + // 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); + + private static readonly CreateKeyDelegate _createKey; + private static readonly MethodInfo _createMethod; + private static readonly object s_keys; + + static ZipCryptoStreamFuzzer() + { + 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)!; + + 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 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_keys, 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. + } + 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/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index f0948420b9eaf9..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,33 +6,48 @@ 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, 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(string sourceArchiveFileName, string destinationDirectoryName) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles) { } + 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 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.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.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.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.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; } @@ -43,20 +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; } } + 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.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.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, 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.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, 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.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/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.csproj b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj index 1709589cc4d076..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,18 +17,16 @@ - - - + + + + + - + @@ -37,16 +35,11 @@ - - - - - + + + + + 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..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 @@ -430,6 +430,56 @@ 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); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(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); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(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) @@ -445,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); } } @@ -459,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(); @@ -479,17 +530,13 @@ 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); + await ZipFileExtensions.DoCreateEntryFromFileAsync(archive, fullPath, entryName, compressionLevel, password, encryptionMethod, 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); } 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..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 @@ -400,6 +400,46 @@ 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); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(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); + if (options.EncryptionMethod != ZipEncryptionMethod.None && options.Password.IsEmpty) + throw new ArgumentException(SR.EmptyPassword, nameof(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) @@ -424,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); @@ -437,17 +478,13 @@ 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); + ZipFileExtensions.DoCreateEntryFromFile(archive, fullPath, entryName, compressionLevel, password, encryptionMethod); } 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); } 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..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 @@ -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:
@@ -205,6 +205,79 @@ 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. + /// 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. + private static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(sourceArchiveFileName); + + ZipArchive archive = await OpenAsync(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding, cancellationToken).ConfigureAwait(false); + await using (archive) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + await entry.ExtractRelativeToDirectoryAsync(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. /// @@ -215,7 +288,7 @@ public static async Task ExtractToDirectoryAsync(string sourceArchiveFileName, s /// 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). @@ -247,7 +320,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). @@ -285,7 +358,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 . @@ -326,7 +399,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 . @@ -362,4 +435,94 @@ 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, 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. + private static async Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlyMemory 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) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, 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 cd2b78e64eb558..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:
@@ -188,6 +188,70 @@ 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. + /// 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. + 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); + foreach (ZipArchiveEntry entry in archive.Entries) + { + entry.ExtractRelativeToDirectory(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. /// @@ -197,7 +261,7 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti /// 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). @@ -226,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). @@ -261,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 . @@ -299,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 . @@ -328,5 +392,84 @@ 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, 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. + private static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, ReadOnlySpan 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); + 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 dd60c9c768986e..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,36 @@ 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.

+ ///
+ /// + /// sourceFileName is a zero-length string, contains only whitespace, or contains invalid characters. + /// -or- entryName is a zero-length string. + /// -or- password is null or empty when encryption is not None. + /// + /// sourceFileName or entryName is null. + /// In sourceFileName, the specified path, file name, or both exceed the system-defined maximum length. + /// The specified sourceFileName is invalid. + /// An I/O error occurred while opening the file specified by sourceFileName. + /// sourceFileName specified a directory, or the caller lacks permission. + /// The file specified in sourceFileName was not found. + /// sourceFileName is in an invalid format or the ZipArchive does not support writing. + /// The ZipArchive has already been closed. + /// An asynchronous operation is cancelled. + /// + /// 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 to use for encrypting the entry. + /// The encryption method to use. + /// 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, ZipEncryptionMethod encryption, CancellationToken cancellationToken = default) => + DoCreateEntryFromFileAsync(destination, sourceFileName, entryName, null, password, encryption, cancellationToken); /// ///

Asynchronously adds a file from the file system to the archive under the specified entry name. @@ -78,18 +107,49 @@ 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.

+ ///
+ /// + /// sourceFileName is a zero-length string, contains only whitespace, or contains invalid characters. + /// -or- entryName is a zero-length string. + /// -or- password is null or empty when encryption is not None. + /// + /// sourceFileName or entryName is null. + /// In sourceFileName, the specified path, file name, or both exceed the system-defined maximum length. + /// The specified sourceFileName is invalid. + /// An I/O error occurred while opening the file specified by sourceFileName. + /// sourceFileName specified a directory, or the caller lacks permission. + /// The file specified in sourceFileName was not found. + /// sourceFileName is in an invalid format or the ZipArchive does not support writing. + /// The ZipArchive has already been closed. + /// An asynchronous operation is cancelled. + /// + /// 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 level of the compression (speed/memory vs. compressed size trade-off). + /// The password to use for encrypting the entry. + /// The encryption method to use. + /// 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, 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, - CompressionLevel? compressionLevel, CancellationToken cancellationToken) + CompressionLevel? compressionLevel, ReadOnlyMemory password = default, ZipEncryptionMethod encryption = ZipEncryptionMethod.None, CancellationToken cancellationToken = default) { 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 = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using (es) { await fs.CopyToAsync(es, cancellationToken).ConfigureAwait(false); @@ -98,5 +158,4 @@ internal static async Task DoCreateEntryFromFileAsync(this ZipA return entry; } - } 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..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 @@ -77,10 +77,62 @@ 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); + + + /// + ///

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. + /// If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name. + /// If the specified source file has an invalid last modified time, the first datetime representable in the Zip timestamp format + /// (midnight on January 1, 1980) will be used.

+ ///

If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name.

+ ///
+ /// sourceFileName is a zero-length string, contains only whitespace, or contains one or more + /// invalid characters as defined by InvalidPathChars. -or- entryName is a zero-length string. -or- password is null or empty when encryption is not None. + /// sourceFileName or entryName is null. + /// In sourceFileName, 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 sourceFileName is invalid, (for example, it is on an unmapped drive). + /// An I/O error occurred while opening the file specified by sourceFileName. + /// sourceFileName specified a directory. + /// -or- The caller does not have the required permission. + /// The file specified in sourceFileName was not found. + /// sourceFileName is in an invalid format or the ZipArchive does not support writing. + /// The ZipArchive has already been closed. + /// + /// The zip archive to add the file to. + /// The path to the file on the file system to be copied from. The path is permitted to specify relative + /// or absolute path information. Relative path information is interpreted as relative to the current working directory. + /// 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 entry. + public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, + string sourceFileName, string entryName, CompressionLevel compressionLevel, + ReadOnlySpan password, ZipEncryptionMethod encryption) => + DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); + internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel) + string sourceFileName, string entryName, CompressionLevel? compressionLevel, + ReadOnlySpan password = default, ZipEncryptionMethod encryption = ZipEncryptionMethod.None) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); + + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: false, password, encryption); using (fs) { @@ -93,7 +145,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); @@ -106,9 +158,25 @@ 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); + if (encryption != ZipEncryptionMethod.None && password.IsEmpty) + { + fs.Dispose(); + throw new ArgumentException(SR.EmptyPassword, nameof(password)); + } + + 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 8b42376813a2da..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,25 @@ 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); + } + } + + /// + /// Asynchronously extracts all of the files in the archive to a directory on the file system using the specified options. + /// + public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, ZipExtractionOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destinationDirectoryName); + ArgumentNullException.ThrowIfNull(options); + + cancellationToken.ThrowIfCancellationRequested(); + + foreach (ZipArchiveEntry entry in source.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + 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 ad9cfd4a6c2e63..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 @@ -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. @@ -73,5 +73,24 @@ public static void ExtractToDirectory(this ZipArchive source, string destination entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); } } + + /// + /// Extracts all of the files in the archive to a directory on the file system using the specified options. + /// + /// The zip archive to extract files from. + /// 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); + ArgumentNullException.ThrowIfNull(options); + + foreach (ZipArchiveEntry entry in source.Entries) + { + 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 591e51687e9225..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 @@ -118,7 +118,65 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string } } - internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, CancellationToken cancellationToken = default) + /// + /// 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); + + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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). + string extractPath = destinationFileName; + string? tempPath = null; + + if (overwrite && File.Exists(destinationFileName)) + { + tempPath = Path.GetTempFileName(); + extractPath = tempPath; + } + + try + { + FileStream fs = new FileStream(extractPath, fileStreamOptions); + await using (fs.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); + } + } + + // 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, ReadOnlyMemory password = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -127,7 +185,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, 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 dfc670c3394c02..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 @@ -105,6 +105,62 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination } } + /// + /// 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 extraction options including password and overwrite behavior. + /// , , or is . + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, ZipExtractionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + ExtractToFile(source, destinationFileName, options.OverwriteFiles, options.Password.Span); + } + + private static void ExtractToFile(ZipArchiveEntry source, string destinationFileName, bool overwrite, ReadOnlySpan password) + { + 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). + 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 = source.Open(password)) + 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, bool useAsync, out FileStreamOptions fileStreamOptions) { ArgumentNullException.ThrowIfNull(source); @@ -185,14 +241,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, ReadOnlySpan password = default) { 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 (!password.IsEmpty) + ExtractToFile(source, fileDestinationPath, overwrite, password); + else + source.ExtractToFile(fileDestinationPath, overwrite: overwrite); } } } 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..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 @@ -16,32 +16,23 @@ + - - - - - - - - - - + + + + + + + + + + @@ -51,4 +42,7 @@ + + + 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..4774b1c9386154 --- /dev/null +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -0,0 +1,1607 @@ +// 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.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + public class ZipFile_EncryptionTests : ZipFileTestBase + { + public static IEnumerable EncryptionMethodAndBoolTestData() + { + foreach (var method in new[] + { + ZipEncryptionMethod.ZipCrypto, + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (ZipEncryptionMethod?)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(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (ZipEncryptionMethod?)encryptionMethod), + ("folder/file2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)"pass", (ZipEncryptionMethod?)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_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task Encryption_Combinations_RoundTrip(bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("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); + + 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_Booleans_Data))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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 = ZipEncryptionMethod.Aes256; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, password, encryptionMethod); + Stream s = entry.Open(); + 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] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public void WrongPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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 MissingPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("test.txt"); + Assert.Throws(() => entry.Open()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task OpeningPlainEntryWithPassword_Succeeds(bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] { ("plain.txt", "content", (string?)null, (ZipEncryptionMethod?)null) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("plain.txt"); + Assert.NotNull(entry); + + // Password is ignored for unencrypted entries + using Stream s = entry.Open("password"); + using var reader = new StreamReader(s); + Assert.Equal("content", reader.ReadToEnd()); + } + } + + [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(); + string password = "pass"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + Assert.Equal("content", await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + Assert.Equal("content", File.ReadAllText(destFile)); + } + } + } + + private string GetTempArchivePath() => GetTestFilePath(); + + 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; + if (password != null && encryption.HasValue) + { + entry = archive.CreateEntry(name, password, encryption.Value); + } + else + { + entry = archive.CreateEntry(name); + } + Stream 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); + } + } + + #region Update Mode Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipEncryptionMethod 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, (ZipEncryptionMethod?)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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipEncryptionMethod 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, (ZipEncryptionMethod?)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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + var encryptionMethod = ZipEncryptionMethod.Aes256; + + var entries = new[] + { + ("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); + + // 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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 = ZipEncryptionMethod.Aes256; + + // Create archive with large encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, password, encryptionMethod); + using (Stream s = entry.Open()) + { + 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(ZipEncryptionMethod 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, (ZipEncryptionMethod?)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] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public void UpdateMode_EncryptedEntry_WrongPassword_Throws() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + 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)) + { + 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, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; + 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("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); + + // 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipEncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("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); + + // 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipEncryptionMethod 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, (ZipEncryptionMethod?)encryptionMethod), + ("entry2.txt", "Content 2", (string?)password, (ZipEncryptionMethod?)encryptionMethod), + ("entry3.txt", "Content 3", (string?)password, (ZipEncryptionMethod?)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 + + #region CompressionMethod Property Tests for Encrypted Entries + + [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(); + string password = "password123"; + + // Create archive with entries using different AES strengths + var entries = new[] + { + ("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); + + // 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 + + #region Zip64 Tests for Encrypted Entries + + [Theory] + [SkipOnCI("Takes significant time and disk space to create 4GB+ files")] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipEncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + // Skip if insufficient disk space + long requiredSpace = 5L * 1024 * 1024 * 1024; // 5GB + string? root = Path.GetPathRoot(Path.GetFullPath(archivePath)); + if (root is null) + return; + + DriveInfo drive = new DriveInfo(root); + if (drive.AvailableFreeSpace < requiredSpace * 2) + return; + + string entryName = "zip64_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 + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression, password, encryptionMethod); + using (Stream s = entry.Open()) + { + 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 + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + Assert.Equal(size, entry.Length); + + // Only verify first and last chunks to reduce test time + using (Stream s = entry.Open(password)) + { + byte[] readBuffer = new byte[bufferSize]; + + // Verify first chunk + int firstRead = async + ? await s.ReadAsync(readBuffer) + : s.Read(readBuffer); + Assert.Equal(bufferSize, firstRead); + Assert.True(readBuffer.AsSpan().SequenceEqual(buffer.AsSpan())); + + // Skip to near the end (seek not supported, so we read through) + long toSkip = size - (2L * bufferSize); + while (toSkip > 0) + { + int skipRead = async + ? await s.ReadAsync(readBuffer.AsMemory(0, (int)Math.Min(bufferSize, toSkip))) + : s.Read(readBuffer, 0, (int)Math.Min(bufferSize, toSkip)); + if (skipRead == 0) break; + toSkip -= skipRead; + } + + // Verify we can still read (stream integrity) + int lastRead = async + ? await s.ReadAsync(readBuffer) + : s.Read(readBuffer); + Assert.True(lastRead > 0); + } + } + } + finally + { + if (File.Exists(archivePath)) + File.Delete(archivePath); + } + } + + [Theory] + [SkipOnCI("Takes significant time and disk space to create 4GB+ files")] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipEncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + long requiredSpace = 5L * 1024 * 1024 * 1024; + string? root = Path.GetPathRoot(Path.GetFullPath(archivePath)); + if (root is null) + return; + + DriveInfo drive = new DriveInfo(root); + if (drive.AvailableFreeSpace < requiredSpace * 2) + return; + + string entryName = "zip64_large.bin"; + long size = (long)uint.MaxValue + (1024 * 1024); + string password = "Zip64Password!"; + int bufferSize = 64 * 1024 * 1024; + + byte[] buffer = new byte[bufferSize]; + new Random(42).NextBytes(buffer); + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression, password, encryptionMethod); + using (Stream s = entry.Open()) + { + 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; + } + } + } + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + Assert.Throws(() => entry.Open(password)); + } + } + finally + { + if (File.Exists(archivePath)) + File.Delete(archivePath); + } + } + + #endregion + + #region ExtractToFile Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (ZipEncryptionMethod?)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, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (ZipEncryptionMethod?)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, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [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(); + string password = "CorrectPassword"; + var entries = new[] { ("encrypted.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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, new ZipExtractionOptions { OverwriteFiles = false, Password = "WrongPassword".AsMemory() })); + } + else + { + Assert.Throws(() => entry.ExtractToFile(destFile, new ZipExtractionOptions { OverwriteFiles = false, Password = "WrongPassword".AsMemory() })); + } + } + } + + #endregion + + #region ExtractToDirectory Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (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); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await archive.ExtractToDirectoryAsync(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); + } + else + { + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() }); + } + } + + // 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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", (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); + + 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, new ZipExtractionOptions { OverwriteFiles = false, Password = "Password1".AsMemory() })); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = "Password1".AsMemory() })); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + 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, (ZipEncryptionMethod?)encryptionMethod), + ("file2.txt", "New Content 2", (string?)password, (ZipEncryptionMethod?)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, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + } + else + { + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = true, Password = password.AsMemory() }); + } + } + + // 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))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword"; + var entries = new[] + { + ("file1.txt", "New Content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.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, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() })); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, new ZipExtractionOptions { OverwriteFiles = false, Password = password.AsMemory() })); + } + } + + // Verify existing file was not modified + Assert.Equal("Existing Content", File.ReadAllText(Path.Combine(tempDir.Path, "file1.txt"))); + } + + #endregion + + #region Open(FileAccess, ...) Tests + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Open_FileAccess_ReadMode_WriteAccess_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "secret"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipEncryptionMethod?)ZipEncryptionMethod.ZipCrypto) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + + + if (async) + { + await Assert.ThrowsAsync( + () => entry.OpenAsync(FileAccess.Write, password)); + + await Assert.ThrowsAsync( + () => entry.OpenAsync(FileAccess.ReadWrite, password)); + } + + else + { + Assert.Throws(() => entry.Open(FileAccess.Write, password)); + Assert.Throws(() => entry.Open(FileAccess.ReadWrite, password)); + } + } + } + + [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(); + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("test.txt"); + + if (async) + { + // Read access in create mode throws + 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)); + } + else + { + Assert.Throws(() => entry.Open(FileAccess.Read, "password")); + Assert.Throws(() => archive.CreateEntry("test_null.txt", (string)null!, ZipEncryptionMethod.Aes256)); + Assert.Throws(() => archive.CreateEntry("test_empty.txt", "", ZipEncryptionMethod.Aes256)); + } + } + } + + [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(); + string password = "secret"; + 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)) + { + ZipArchiveEntry entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + if (async) + { + 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 + { + 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, "")); + } + } + } + + #endregion + + #region CreateEntryFromFile Overload Tests + + public static IEnumerable CreateEntryFromFile_Encrypted_TestData() + { + foreach (var row in EncryptionMethodAndBoolTestData()) + { + var method = (ZipEncryptionMethod)row[0]; + var async = (bool)row[1]; + + yield return new object[] { method, false, async }; // no compression overload + yield return new object[] { method, true, async }; // compression overload + } + } + + [Theory] + [MemberData(nameof(CreateEntryFromFile_Encrypted_TestData))] + [SkipOnPlatform(TestPlatforms.Browser, "WinZip AES encryption is not supported on Browser")] + public async Task CreateEntryFromFile_Encrypted_RoundTrip(ZipEncryptionMethod encryptionMethod, bool useCompression, bool async) + { + string archivePath = GetTempArchivePath(); + string sourcePath = GetTestFilePath(); + string entryName = useCompression ? "fromfile-compressed.txt" : "fromfile.txt"; + string password = "password123"; + string content = useCompression ? "Secret content with compression from file" : "Secret content from file"; + + if (async) + await File.WriteAllTextAsync(sourcePath, content, Encoding.UTF8); + else + File.WriteAllText(sourcePath, content, Encoding.UTF8); + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + if (async) + { + if (useCompression) + await archive.CreateEntryFromFileAsync(sourcePath, entryName, CompressionLevel.Optimal, password.AsMemory(), encryptionMethod); + else + await archive.CreateEntryFromFileAsync(sourcePath, entryName, password.AsMemory(), encryptionMethod); + } + else + { + if (useCompression) + archive.CreateEntryFromFile(sourcePath, entryName, CompressionLevel.Optimal, password, encryptionMethod); + else + archive.CreateEntryFromFile(sourcePath, entryName, password, encryptionMethod); + } + } + + 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); + } + } + + #endregion + + #region Browser Platform Tests + + public static IEnumerable AesEncryptionMethodAndBoolTestData() + { + foreach (var method in new[] + { + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.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, password, ZipEncryptionMethod.ZipCrypto); + using (Stream s = entry.Open()) + 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(ZipEncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string password = "password123"; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + Assert.Throws(() => archive.CreateEntry(entryName, password, encryptionMethod)); + } + } + + #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 f9ce507af62804..97135de44d0265 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -145,6 +145,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; } @@ -164,14 +166,19 @@ 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; } } public void Delete() { } public System.IO.Stream Open() { 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.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.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.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; } } public enum ZipArchiveMode @@ -186,6 +193,15 @@ public enum ZipCompressionMethod Deflate = 8, Deflate64 = 9, } + public enum ZipEncryptionMethod + { + Unknown = -1, + None = 0, + ZipCrypto = 1, + Aes128 = 2, + Aes192 = 3, + Aes256 = 4, + } public sealed partial class ZLibCompressionOptions { public static int DefaultWindowLog { get { throw null; } } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index bbb10afbcf342a..c92d4a98f947aa 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 @@ + - + - - - + + + @@ -109,9 +109,10 @@ + - + \ 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 new file mode 100644 index 00000000000000..8f112454fe0fae --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesKeyMaterial.cs @@ -0,0 +1,126 @@ +// 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; + +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 unsafe 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 + { + Debug.Assert(salt.Length == saltSize, $"Salt must be {saltSize} bytes for AES-{keySizeBits}."); + saltBytes = salt; + } + + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + byte[] rentedPasswordBytes = System.Buffers.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]; + + try + { + int actualByteCount = Encoding.UTF8.GetBytes(password, rentedPasswordBytes); + Span passwordSpan = rentedPasswordBytes.AsSpan(0, actualByteCount); + + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + saltBytes, + derivedKey, + 1000, // iteration count specified by the WinZip AE-1/AE-2 specification + 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 new file mode 100644 index 00000000000000..41be032b047f55 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -0,0 +1,721 @@ +// 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.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO.Compression +{ + [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) + + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly Aes _aes; + private IncrementalHash? _hmac; + private UInt128 _counter = 1; + private readonly byte[] _salt; + private readonly byte[] _passwordVerifier; + private bool _headerWritten; + private bool _disposed; + // 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; + 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 + + // 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(ReadOnlySpan password, byte[]? salt, int keySizeBits) + => 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) + { + ArgumentNullException.ThrowIfNull(baseStream); + + if (!encrypting) + { + ReadAndValidateHeaderCore(isAsync: false, baseStream, keyMaterial, CancellationToken.None).GetAwaiter().GetResult(); + } + + return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); + } + + /// + /// Creates a WinZipAesStream asynchronously. Reads and validates the header for decryption. + /// + internal static async Task CreateAsync(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseStream); + + if (!encrypting) + { + await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyMaterial, cancellationToken).ConfigureAwait(false); + } + + return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); + } + + /// + /// Reads and validates the WinZip AES header (salt + password verifier) from the stream. + /// + private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, WinZipAesKeyMaterial keyMaterial, CancellationToken cancellationToken) + { + int saltSize = keyMaterial.SaltSize; + + // Read salt from stream + byte[] fileSalt = new byte[saltSize]; + if (isAsync) + { + await baseStream.ReadExactlyAsync(fileSalt, cancellationToken).ConfigureAwait(false); + } + else + { + baseStream.ReadExactly(fileSalt); + } + + // Read the 2-byte password verifier from stream + byte[] verifier = new byte[2]; + if (isAsync) + { + await baseStream.ReadExactlyAsync(verifier, cancellationToken).ConfigureAwait(false); + } + else + { + baseStream.ReadExactly(verifier); + } + + // 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); + } + + // 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 by Create/CreateAsync. + /// For decryption, the header must already be validated before calling this constructor. + /// + 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; + + _aes = Aes.Create(); + _aes.Mode = CipherMode.ECB; + _aes.Padding = PaddingMode.None; + + _salt = keyMaterial.Salt; + _passwordVerifier = keyMaterial.PasswordVerifier; + + if (encrypting) + { + _encryptedDataSize = -1; + _encryptedDataRemaining = -1; + } + else + { + int headerSize = checked(keyMaterial.SaltSize + 2); // Salt + Password Verifier + const int hmacSize = 10; // 10-byte HMAC + + _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + _encryptedDataRemaining = _encryptedDataSize; + + if (_encryptedDataSize < 0) + { + throw new InvalidDataException(SR.InvalidWinZipSize); + } + } + + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, keyMaterial.HmacKey); + _aes.SetKey(keyMaterial.EncryptionKey); + } + + private unsafe void FinalizeAndCompareHMAC(byte[] 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[SHA1.HashSizeInBytes]; + 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.Slice(0, 10))) + { + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); + } + } + + private void ValidateAuthCode() + { + 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 async Task ValidateAuthCodeAsync(CancellationToken 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 WriteHeaderAsync(CancellationToken cancellationToken) + { + if (_headerWritten) + { + return; + } + + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); + + _headerWritten = true; + } + + private void WriteHeader() + { + if (_headerWritten) + { + return; + } + + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + _headerWritten = true; + } + + private void ProcessBlock(Span buffer) + { + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); + + int processed = 0; + + while (processed < buffer.Length) + { + // Ensure we have enough keystream bytes available + int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; + if (keystreamAvailable == 0) + { + GenerateKeystreamBuffer(); + keystreamAvailable = KeystreamBufferSize; + } + + // Process as many bytes as possible with the available keystream + int bytesToProcess = Math.Min(buffer.Length - processed, keystreamAvailable); + + Span dataSpan = buffer.Slice(processed, bytesToProcess); + ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); + + if (_encrypting) + { + // For encryption: XOR first, then HMAC the ciphertext + XorBytes(dataSpan, keystreamSpan); + _hmac.AppendData(dataSpan); + } + else + { + // For decryption: HMAC first (on ciphertext), then XOR + _hmac.AppendData(dataSpan); + XorBytes(dataSpan, keystreamSpan); + } + + _keystreamOffset += bytesToProcess; + processed += bytesToProcess; + } + } + + private void GenerateKeystreamBuffer() + { + // Fill the buffer with all counter values first + for (int i = 0; i < KeystreamBufferSize; i += BlockSize) + { + 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; + } + + private static void XorBytes(Span dest, ReadOnlySpan src) + { + Debug.Assert(dest.Length <= src.Length); + + for (int i = 0; i < dest.Length; i++) + { + dest[i] ^= src[i]; + } + } + + 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 (_authCodeFinalized) + { + return; + } + + byte[] authCode = new 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) + { + // 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); + } + else + { + _baseStream.Write(authCode.AsSpan(0, 10)); + } + + _authCodeFinalized = true; + } + + 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); + } + + 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) + { + // Only validate auth code when we've actually reached end of encrypted data, + // not when caller simply requested 0 bytes + if (_encryptedDataRemaining <= 0) + { + ValidateAuthCode(); + } + return 0; + } + + Span readBuffer = buffer.Slice(0, bytesToRead); + int bytesRead = _baseStream.Read(readBuffer); + + if (bytesRead > 0) + { + _encryptedDataRemaining -= bytesRead; + ProcessBlock(readBuffer.Slice(0, bytesRead)); + + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) + { + ValidateAuthCode(); + } + } + else if (_encryptedDataRemaining > 0) + { + // Base stream returned 0 bytes but we expected more encrypted data - stream is truncated + throw new InvalidDataException(SR.UnexpectedEndOfStream); + } + + return bytesRead; + } + + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfNotReadable(); + + int bytesToRead = GetBytesToRead(buffer.Length); + if (bytesToRead == 0) + { + // Only validate auth code when we've actually reached end of encrypted data, + // not when caller simply requested 0 bytes + if (_encryptedDataRemaining <= 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } + return 0; + } + + int bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + _encryptedDataRemaining -= bytesRead; + ProcessBlock(buffer.Span.Slice(0, bytesRead)); + + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } + } + else if (_encryptedDataRemaining > 0) + { + // Base stream returned 0 bytes but we expected more encrypted data - stream is truncated + throw new InvalidDataException(SR.UnexpectedEndOfStream); + } + + return bytesRead; + } + + private byte[] GetWriteWorkBuffer() => _writeWorkBuffer ??= new byte[KeystreamBufferSize]; + + 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)); + + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= copyCount; + + // If full, encrypt and write immediately + if (_partialBlockBytes == BlockSize) + { + ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); + _baseStream.Write(_partialBlock, 0, BlockSize); + _partialBlockBytes = 0; + } + } + + while (inputCount > 0) + { + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); + _baseStream.Write(workBuffer, 0, bytesToProcess); + + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } + } + + private void ThrowIfNotWritable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + { + throw new NotSupportedException(SR.WritingNotSupported); + } + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + ThrowIfNotWritable(); + if (!_headerWritten) + { + WriteHeader(); + } + + WriteCore(buffer, GetWriteWorkBuffer()); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return WriteAsyncCore(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + private async ValueTask WriteAsyncCore(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfNotWritable(); + if (!_headerWritten) + { + 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)); + + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= 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 (inputCount > 0) + { + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); + await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); + + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + 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) + { + return; + } + + if (disposing) + { + try + { + if (_encrypting && !_authCodeFinalized) + { + FinishEncryptingAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } + } + finally + { + _disposed = true; + _aes.Dispose(); + _hmac?.Dispose(); + + if (!_leaveOpen) + { + _baseStream.Dispose(); + } + } + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + try + { + if (_encrypting && !_authCodeFinalized) + { + await FinishEncryptingAsync(isAsync: true, CancellationToken.None).ConfigureAwait(false); + } + } + finally + { + _aes.Dispose(); + _hmac?.Dispose(); + + if (!_leaveOpen) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + } + } + + _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(); + 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 async Task FlushAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + 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/ZipArchive.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.Async.cs index cb72e9d99c8b28..880c9cb35c2329 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 ca20d274e498f5..242eeac4639f50 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. /// @@ -630,6 +675,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 a393ea76491e0f..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 @@ -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,40 +45,97 @@ 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(); + ValidateAccessForMode(access); + return OpenAsyncCore(access, default, cancellationToken); + } + + /// + /// Asynchronously opens the entry with the specified access mode and password for decrypting encrypted entries. + /// + /// The file access mode for the returned stream. + /// The password used to decrypt the encrypted entry. + /// The token to monitor for cancellation requests. + /// A that represents the asynchronous open operation. + /// + /// The allowed values depend on the : + /// + /// : Only is allowed. + /// : 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. + /// + /// + /// is not a valid value. + /// The requested access is not compatible with the archive's open 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, ReadOnlySpan password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfInvalidArchive(); + ValidateAccessForMode(access); + + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + + return OpenAsyncCore(access, password, cancellationToken); + } + + /// + /// 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(); 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)); + + return OpenAsyncCore(InferAccessFromMode(), password, cancellationToken); + } + + private Task OpenAsyncCore(FileAccess access, ReadOnlySpan password, CancellationToken cancellationToken) + { + bool usePassword = IsEncrypted && !password.IsEmpty; - // 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); - + return OpenInReadModeAsync(checkOpenable: true, password, cancellationToken); case ZipArchiveMode.Create: - if (access == FileAccess.Read) - throw new InvalidOperationException(SR.CannotBeReadInCreateMode); - return OpenInWriteMode(); - + return Task.FromResult(OpenInWriteMode()); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) + // 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 { - 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); - } + 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)), + }; } } @@ -98,30 +144,185 @@ 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 + + // 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; } 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 Task OpenInReadModeAsync(bool checkOpenable, ReadOnlySpan password, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // 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 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) + { + cancellationToken.ThrowIfCancellationRequested(); + 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) + { + _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 == null) + if (_storedUncompressedData is null) { - // this means we have never opened it before + if (_uncompressedSize > Array.MaxLength) + throw new InvalidDataException(SR.EntryTooLarge); - // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just - // grow as data is copied into it _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { - Stream decompressor = await OpenInReadModeAsync(false, cancellationToken).ConfigureAwait(false); + Stream decompressor = await OpenInReadModeAsync(checkOpenable: false, default, cancellationToken).ConfigureAwait(false); + await using (decompressor) { try @@ -130,15 +331,12 @@ 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; _everOpenedForWrite = false; + _derivedZipCryptoKeyMaterial = null; + _derivedAesKeyMaterial = null; throw; } } @@ -178,14 +376,26 @@ 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 extra fields (and any malformed trailing data). - await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _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 (UseAesEncryption()) + { + 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); + } + 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) { @@ -193,7 +403,6 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel } } } - internal async Task LoadLocalHeaderExtraFieldIfNeededAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -243,39 +452,133 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo throw new InvalidDataException(message); } - private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) + /// + /// 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) { - cancellationToken.ThrowIfCancellationRequested(); - if (checkOpenable) - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); + 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. + if (IsEncrypted) + { + SetupEncryptionKeyMaterial(password); + } + + if (loadExistingContent && IsEncrypted) + { + 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 DecryptAndStoreForUpdateWithAesAsync(aesKeys, cancellationToken); + } + + if (IsZipCryptoEncrypted) + { + byte checkByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keys = ZipCryptoStream.CreateKey(password); + return DecryptAndStoreForUpdateWithZipCryptoAsync(keys, checkByte, cancellationToken); + } - return OpenInReadModeGetDataCompressor( - await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false)); + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + return CastToStreamAsync(OpenInUpdateModeAsync(loadExistingContent, cancellationToken)); } - private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default) + private static async Task CastToStreamAsync(Task task) => await task.ConfigureAwait(false); + + private async Task DecryptAndStoreForUpdateWithZipCryptoAsync(ZipCryptoKeys keys, byte checkByte, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - if (_currentlyOpenForWrite) - throw new IOException(SR.UpdateModeOneStream); + await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); - if (loadExistingContent) + 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(WinZipAesKeyMaterial aesKeys, CancellationToken cancellationToken) + { + if (OperatingSystem.IsBrowser()) { - await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); + 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, + keyMaterial: aesKeys, + totalStreamSize: _compressedSize, + encrypting: false, + cancellationToken: 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).ConfigureAwait(false); - } - else + if (_uncompressedSize > Array.MaxLength) + throw new InvalidDataException(SR.EntryTooLarge); + + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); + + Stream decompressed = BuildDecompressionPipeline(decryptedStream); + + await using (decompressed) { - _storedUncompressedData?.Dispose(); - _storedUncompressedData = new MemoryStream(); - // Opening with loadExistingContent: false discards existing content, which is a modification - MarkAsModified(); + try + { + 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; + } } _storedUncompressedData.Seek(0, SeekOrigin.Begin); @@ -299,11 +602,24 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent { 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 && (ushort)_headerCompressionMethod == WinZipAesMethod) + { + _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. + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + { + message = SR.LocalFileHeaderCorrupt; + return (false, message); + } + } // when this property gets called, some duplicated work long offsetOfCompressedData = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); @@ -335,7 +651,19 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } - await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _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 (UseAesEncryption()) + { + 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); + } + else + { + await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } } return zip64ExtraField != null; @@ -364,18 +692,134 @@ 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), this); - await using (entryWriter) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + 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 + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + var encryptionStream = ZipCryptoStream.Create( + baseStream: _archive.ArchiveStream, + keys: _derivedZipCryptoKeyMaterial.Value, + passwordVerifierLow2Bytes: verifierLow2Bytes, + encrypting: true, + 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() && _derivedAesKeyMaterial != 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 + + // WriteLocalFileHeaderAsync will set CompressionMethod = Aes + bool usedZip64InLH = await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, preserveDataDescriptor: false, cancellationToken).ConfigureAwait(false); + + // 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; + + var 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 + 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 - AesCompressionMethodValue is used directly when writing headers + CompressionMethod = savedMethod; + } + 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; + + // Patch CRC and sizes back into the local header + await WriteCrcAndSizesInLocalHeaderAsync(usedZip64InLH, cancellationToken).ConfigureAwait(false); + + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + else { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - await _storedUncompressedData.CopyToAsync(entryWriter, cancellationToken).ConfigureAwait(false); + // 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) { @@ -383,8 +827,48 @@ 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; + 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) + { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + Encryption = ZipEncryptionMethod.None; + } + 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) + { + 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; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -394,6 +878,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 e84666144c9379..4b51418c371334 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; @@ -47,8 +46,18 @@ public partial class ZipArchiveEntry private List? _lhUnknownExtraFields; private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; + private ZipEncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - + private ZipCompressionMethod _headerCompressionMethod; + 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 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) { @@ -66,8 +75,46 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _versionMadeBySpecification = (ZipVersionNeededValues)cd.VersionMadeBySpecification; _versionToExtract = (ZipVersionNeededValues)cd.VersionNeededToExtract; _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; - _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; - CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + // 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; + Encryption = aesField.AesStrength switch + { + 1 => ZipEncryptionMethod.Aes128, + 2 => ZipEncryptionMethod.Aes192, + 3 => ZipEncryptionMethod.Aes256, + _ => throw new InvalidDataException(SR.InvalidAesStrength) + }; + } + else if (IsEncrypted) + { + 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 + { + // 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; @@ -97,7 +144,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) @@ -172,7 +218,19 @@ 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. + /// + /// + /// if the entry is not encrypted; + /// if the entry uses an unsupported encryption method; + /// otherwise, the specific encryption method (e.g., , + /// , , + /// or ). + /// + public ZipEncryptionMethod EncryptionMethod => _encryptionMethod; /// /// Gets the compression method used to compress the entry. @@ -275,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, @@ -365,18 +428,28 @@ public void Delete() public Stream Open() { ThrowIfInvalidArchive(); + return OpenCore(InferAccessFromMode()); + } - 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(); - } + + /// + /// 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 used to decrypt the entry. If the entry is not encrypted, this parameter is ignored. + public Stream Open(ReadOnlySpan password) + { + ThrowIfInvalidArchive(); + + if (IsEncrypted && password.IsEmpty) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + + return OpenCore(InferAccessFromMode(), password); } /// @@ -400,48 +473,66 @@ public Stream Open() 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(); + break; + } + } + private Stream OpenCore(FileAccess access, ReadOnlySpan password = default) + { + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + return OpenInReadMode(checkOpenable: true, password); + case ZipArchiveMode.Create: + return OpenInWriteMode(); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - switch (access) + return access switch { - case FileAccess.Read: - return OpenInReadMode(checkOpenable: true); - case FileAccess.Write: - return OpenInUpdateMode(loadExistingContent: false); - case FileAccess.ReadWrite: - default: - return 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); @@ -465,29 +556,77 @@ 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 + + // 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; } + return _storedOffsetOfCompressedData.Value; } - private MemoryStream GetUncompressedData() + /// + /// 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) { // 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) { - using (Stream decompressor = OpenInReadMode(false)) + Stream decompressor = !password.IsEmpty + ? OpenInReadMode(checkOpenable: false, password) + : OpenInReadMode(checkOpenable: false); + + using (decompressor) { try { @@ -504,6 +643,8 @@ private MemoryStream GetUncompressedData() _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; + _derivedZipCryptoKeyMaterial = null; + _derivedAesKeyMaterial = null; throw; } } @@ -595,14 +736,27 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 } + WinZipAesExtraField? aesExtraField = null; + int aesExtraFieldSize = 0; + + if (UseAesEncryption()) + { + aesExtraField = CreateAesExtraField(); + 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); + // 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; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _cdUnknownExtraFields = null; } else @@ -614,8 +768,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); @@ -652,9 +805,16 @@ 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)WinZipAesMethod : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], _crc32); + // when using aes encryption, ae-2 standard dictates crc to be 0 + 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); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.FilenameLength..], (ushort)_storedEntryNameBytes.Length); @@ -680,8 +840,19 @@ internal unsafe void WriteCentralDirectoryFileHeader(bool forceWrite) // only write zip64ExtraField if we decided we need it (it's not null) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); - // write extra fields (and any malformed trailing data). - ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // Write AES extra field if using AES encryption + if (UseAesEncryption()) + { + 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); + } + else + { + // write extra fields (and any malformed trailing data). + ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } if (_fileComment.Length > 0) { @@ -755,7 +926,8 @@ 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 @@ -788,12 +960,11 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool default: compressorStreamFactory = () => new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); break; - } bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; var checkSumStream = new CheckSumAndSizeWriteStream( compressorStreamFactory, - backingStream, + streamForPosition ?? backingStream, leaveCompressorStreamOpenOnClose, this, onClose, @@ -808,6 +979,82 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool return checkSumStream; } + 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); + } + + 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 IsAesEncrypted => (ushort)_headerCompressionMethod == WinZipAesMethod; + + private static int GetAesKeySizeBits(ZipEncryptionMethod encryption) + { + return encryption switch + { + ZipEncryptionMethod.Aes128 => 128, + ZipEncryptionMethod.Aes192 => 192, + ZipEncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + } + + /// + /// 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); + } + + if (_aesSalt is null) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } + + int keySizeBits = GetAesKeySizeBits(Encryption); + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, _aesSalt, keySizeBits); + return WinZipAesStream.Create( + baseStream: compressedStream, + keyMaterial: keyMaterial, + totalStreamSize: _compressedSize, + encrypting: false); + } + + 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) { Stream? uncompressedStream; @@ -820,11 +1067,15 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) uncompressedStream = new DeflateManagedStream(compressedStreamToRead, ZipCompressionMethod.Deflate64, _uncompressedSize); break; case ZipCompressionMethod.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 == ZipCompressionMethod.Stored); + // We should not get here with Aes as CompressionMethod anymore + // as it should have been replaced with the actual compression method + Debug.Assert((ushort)CompressionMethod != WinZipAesMethod, + "AES compression method should have been replaced with actual compression method"); + // Fallback to stored if we somehow get here uncompressedStream = compressedStreamToRead; break; } @@ -832,21 +1083,45 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) return uncompressedStream; } - private CrcValidatingReadStream OpenInReadMode(bool checkOpenable) + private Stream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = default) { if (checkOpenable) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); - return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData()); + return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } - private CrcValidatingReadStream OpenInReadModeGetDataCompressor(long offsetOfCompressedData) + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlySpan password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - Stream decompressedStream = GetDataDecompressor(compressedStream); + Stream streamToDecompress; - return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); + if (IsEncrypted) + { + streamToDecompress = WrapWithDecryptionIfNeeded(compressedStream, password); + } + else + { + streamToDecompress = compressedStream; + } + + 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 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) @@ -862,23 +1137,81 @@ private WrappedStream OpenInWriteModeCore() { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; - CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => + + // 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 == ZipEncryptionMethod.ZipCrypto) { - // release the archive stream - var entry = (ZipArchiveEntry)o!; - entry._archive.ReleaseArchiveStream(entry); - entry._outstandingWriteStream = null; - }); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); + ZipCryptoKeys keyMaterial = _derivedZipCryptoKeyMaterial + ?? throw new InvalidOperationException(SR.EmptyPassword); + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + targetStream = ZipCryptoStream.Create( + baseStream: _archive.ArchiveStream, + keys: keyMaterial, + passwordVerifierLow2Bytes: verifierLow2Bytes, + encrypting: true, + crc32: null, + leaveOpen: true); + encryptionStream = targetStream; + } + 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); + + targetStream = WinZipAesStream.Create( + baseStream: _archive.ArchiveStream, + keyMaterial: keyMaterial, + totalStreamSize: -1, + encrypting: true, + leaveOpen: true); + encryptionStream = targetStream; + } + + bool isAesEncryption = encryptionMethod is ZipEncryptionMethod.Aes256 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes128; + + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( + targetStream, + leaveBackingStreamOpen: !isAesEncryption, + (object? o, EventArgs e) => + { + // release the archive stream + var entry = (ZipArchiveEntry)o!; + entry._archive.ReleaseArchiveStream(entry); + entry._outstandingWriteStream = null; + }, + streamForPosition: encryptionMethod != ZipEncryptionMethod.None ? _archive.ArchiveStream : null); + + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod, encryptionStream); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - private WrappedStream OpenInUpdateMode(bool loadExistingContent = true) + 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) { ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); @@ -886,9 +1219,15 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true) _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(); + _storedUncompressedData = GetUncompressedData(password); } else { @@ -921,6 +1260,86 @@ internal void MarkAsModified() } } + /// + /// Sets up encryption key material for re-encryption when writing back to the archive. + /// + private void SetupEncryptionKeyMaterial(ReadOnlySpan password) + { + // Derive and save key material for re-encryption + if (IsZipCryptoEncrypted) + { + _derivedZipCryptoKeyMaterial = ZipCryptoStream.CreateKey(password); + Encryption = ZipEncryptionMethod.ZipCrypto; + } + 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); + _derivedAesKeyMaterial = WinZipAesStream.CreateKey(password, salt: null, keySizeBits); + // Encryption is already set from constructor (parsed from central directory AES extra field) + } + } + + /// + /// 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)); + } + + if (encryptionMethod is ZipEncryptionMethod.None or ZipEncryptionMethod.Unknown) + { + throw new ArgumentOutOfRangeException(nameof(encryptionMethod), SR.EncryptionNotSpecified); + } + + 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. + /// + private WinZipAesExtraField CreateAesExtraField() + { + return new WinZipAesExtraField + { + VendorVersion = 2, + AesStrength = Encryption switch + { + ZipEncryptionMethod.Aes128 => (byte)1, + ZipEncryptionMethod.Aes192 => (byte)2, + ZipEncryptionMethod.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; @@ -931,14 +1350,28 @@ 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 && IsAesEncrypted) + { + _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 + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + { + message = SR.LocalFileHeaderCorrupt; + return false; + } + } - // 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; @@ -955,6 +1388,9 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m message = null; if (needToUncompress) { + // 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) @@ -1061,6 +1497,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; + internal ZipEncryptionMethod Encryption { get => _encryptionMethod; private set => _encryptionMethod = value; } private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, bool preserveDataDescriptor, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength, out uint crc32ToWrite) { @@ -1074,6 +1511,10 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW // 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) { @@ -1085,28 +1526,43 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW } else { - // 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) + bool isCreateMode = _archive.Mode == ZipArchiveMode.Create; + + if (Encryption == ZipEncryptionMethod.ZipCrypto) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + } + else if (UseAesEncryption()) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + aesExtraField = CreateAesExtraField(); + aesExtraFieldSize = WinZipAesExtraField.TotalSize; + } + 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 { + if (ShouldUseZIP64 #if DEBUG_FORCE_ZIP64 - || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) + || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) #endif ) { 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, @@ -1126,14 +1582,18 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW // 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; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _lhUnknownExtraFields = null; } else @@ -1143,18 +1603,19 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW crc32ToWrite = _crc32; + // For AE-2, CRC is always 0 in the local file header + if (UseAesEncryption()) + { + crc32ToWrite = 0; + } + // If this is an existing, unchanged entry then silently skip forwards. // 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; } @@ -1176,7 +1637,7 @@ private unsafe bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceW zip64ExtraField = new() { CompressedSize = 0, UncompressedSize = 0 }; } } - else + else if (Encryption != ZipEncryptionMethod.ZipCrypto) { _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor; } @@ -1190,7 +1651,11 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint crc32, 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)WinZipAesMethod : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crc32); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); @@ -1211,16 +1676,28 @@ private unsafe bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite, bool _archive.ArchiveStream.Write(lfStaticHeader); _archive.ArchiveStream.Write(_storedEntryNameBytes); - // Only when handling zip64 + // Write Zip64 extra field if needed zip64ExtraField?.WriteBlock(_archive.ArchiveStream); - ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // Write AES extra field if using AES encryption + if (UseAesEncryption()) + { + 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); + } + else + { + // Write other extra fields + ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } } 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 @@ -1241,19 +1718,99 @@ 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), - this)) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + if (Encryption == ZipEncryptionMethod.ZipCrypto && _derivedZipCryptoKeyMaterial is not null) { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - _storedUncompressedData.CopyTo(entryWriter); + WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + using (var encryptionStream = ZipCryptoStream.Create( + baseStream: _archive.ArchiveStream, + keys: _derivedZipCryptoKeyMaterial.Value, + passwordVerifierLow2Bytes: verifierLow2Bytes, + encrypting: true, + crc32: null, + leaveOpen: true)) + { + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + } + + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + WriteDataDescriptor(); + + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + else if (UseAesEncryption() && _derivedAesKeyMaterial is not null) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + bool usedZip64InLH = WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + long startPosition = _archive.ArchiveStream.Position; + + bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; + + using (var encryptionStream = WinZipAesStream.Create( + baseStream: _archive.ArchiveStream, + keyMaterial: _derivedAesKeyMaterial.Value, + totalStreamSize: -1, + encrypting: true, + leaveOpen: true)) + { + if (_storedUncompressedData.Length > 0) + { + 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); + } + + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + } + else + { + _crc32 = 0; + _uncompressedSize = 0; + } + } + + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + WriteCrcAndSizesInLocalHeader(usedZip64InLH); + + _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) { @@ -1261,8 +1818,48 @@ 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; + 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) + { + CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; + Encryption = ZipEncryptionMethod.None; + } + 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; + CompressionMethod = savedCompressionMethod; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -1272,6 +1869,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 @@ -1389,8 +1992,9 @@ 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); + // when using aes encryption, ae-2 standard dictates crc to be 0 + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); } @@ -1417,8 +2021,9 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) int bytesToWrite; ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); - BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], _crc32); + // when using aes encryption, ae-2 standard dictates crc to be 0 + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) { @@ -1528,10 +2135,12 @@ private sealed class DirectToArchiveWriterStream : Stream private readonly ZipArchiveEntry _entry; private bool _usedZip64inLH; private bool _canWrite; + 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) + public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, ZipEncryptionMethod encryptionMethod = ZipEncryptionMethod.None, Stream? encryptionStream = null) { _position = 0; _crcSizeStream = crcSizeStream; @@ -1540,6 +2149,8 @@ public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, Zip _entry = entry; _usedZip64inLH = false; _canWrite = true; + _encryption = encryptionMethod; + _encryptionStream = encryptionStream; } public override long Length @@ -1702,8 +2313,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); } @@ -1711,8 +2328,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(); @@ -1730,8 +2356,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, preserveDataDescriptor: false, cancellationToken: default).ConfigureAwait(false); } @@ -1739,8 +2374,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); @@ -1752,12 +2396,12 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } - [Flags] internal enum BitFlagValues : ushort { IsEncrypted = 0x1, DataDescriptor = 0x8, + StrongEncryption = 0x40, UnicodeFileNameAndComment = 0x800 } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs index fea75ba16db328..30445fd5c8d0c6 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 @@ -147,9 +168,8 @@ 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; } 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 ee5bd67e21f65c..8ae37d18089113 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 @@ -613,18 +650,13 @@ public static unsafe List GetExtraFields(Stream stream, ou } } - 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); @@ -652,7 +684,11 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i 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; } @@ -661,17 +697,154 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i public static unsafe 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; } 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) + { + VendorVersion = vendorVersion; + AesStrength = aesStrength; + 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) + + /// + /// 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 && + TryParseData(field.Data.AsSpan(0, field.Size), out aesExtraField)) + { + 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 && + TryParseData(extraFieldData.Slice(offset + 4, fieldSize), out aesExtraField)) + { + return true; + } + + offset += 4 + fieldSize; + } + + 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 unsafe void WriteBlock(Stream stream) + { + Span buffer = stackalloc 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), 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 { // The Zip File Format Specification references 0x02014B50, this is a big endian representation. @@ -705,6 +878,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 @@ -756,6 +937,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); 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 new file mode 100644 index 00000000000000..839a4a4ccb12c0 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -0,0 +1,476 @@ +// 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; +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; + private readonly Stream _base; + private readonly bool _leaveOpen; + private bool _headerWritten; + private bool _disposed; + 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; + 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]; + 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; + } + + private static uint Crc32Update(uint crc, byte b) => s_crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + + // Private decryption constructor - use Create/CreateAsync factory methods instead. + // Keys must already be validated before calling this constructor. + private ZipCryptoStream(Stream baseStream, uint key0, uint key1, uint key2, bool leaveOpen = false) + { + _base = baseStream; + _key0 = key0; + _key1 = key1; + _key2 = key2; + _encrypting = false; + _leaveOpen = leaveOpen; + } + + /// + /// Creates a ZipCryptoStream for decryption. Reads and validates the 12-byte header synchronously. + /// + internal static ZipCryptoStream Create(Stream baseStream, ZipCryptoKeys keys, byte expectedCheckByte, bool encrypting, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(baseStream); + Debug.Assert(!encrypting, "Use the overload with passwordVerifierLow2Bytes for encryption."); + + (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, ZipCryptoKeys keys, byte expectedCheckByte, bool encrypting, CancellationToken cancellationToken = default, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(baseStream); + Debug.Assert(!encrypting, "Use the overload with passwordVerifierLow2Bytes for encryption."); + + (uint key0, uint key1, uint key2) = await ReadAndValidateHeaderCore(isAsync: true, baseStream, keys, expectedCheckByte, cancellationToken).ConfigureAwait(false); + return new ZipCryptoStream(baseStream, key0, key1, key2, leaveOpen); + } + + /// + /// Creates a ZipCryptoStream for encryption. Only synchronous creation is needed since no I/O is performed here. + /// + internal static ZipCryptoStream Create(Stream baseStream, + ZipCryptoKeys keys, + ushort passwordVerifierLow2Bytes, + bool encrypting, + uint? crc32 = null, + bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(baseStream); + Debug.Assert(encrypting, "Use the overload with expectedCheckByte for decryption."); + + return new ZipCryptoStream(baseStream, keys, passwordVerifierLow2Bytes, crc32, leaveOpen); + } + + // Encryption constructor + private ZipCryptoStream(Stream baseStream, + ZipCryptoKeys keys, + ushort passwordVerifierLow2Bytes, + uint? crc32, + bool leaveOpen) + { + _base = baseStream; + _encrypting = true; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = passwordVerifierLow2Bytes; + _crc32ForHeader = crc32; + _key0 = keys.Key0; + _key1 = keys.Key1; + _key2 = keys.Key2; + } + + // 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) + { + // 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) + { + 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)) + { + UpdateKeys(ref key0, ref key1, ref key2, b); + } + + pwSpan = pwSpan.Slice(segment.Length); + } + + return new ZipCryptoKeys(key0, key1, key2); + } + + 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)); + + // bytes 10..11 verifier + if (_crc32ForHeader.HasValue) + { + uint crc = _crc32ForHeader.Value; + BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(10), (ushort)(crc >> 16)); + } + else + { + BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(10), _verifierLow2Bytes); + } + + // encrypt in place + for (int i = 0; i < 12; i++) + { + byte p = header[i]; + byte ks = DecryptByte(_key2); + header[i] = (byte)(p ^ ks); + + // keys updated with PLAINTEXT per ZIP spec + UpdateKeys(ref _key0, ref _key1, ref _key2, p); + } + } + + private unsafe void WriteHeader() + { + if (!_encrypting || _headerWritten) + return; + + Span header = stackalloc byte[12]; + CalculateHeader(header); + _base.Write(header); + _headerWritten = true; + } + + 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() + { + 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 + uint key0 = keys.Key0; + uint key1 = keys.Key1; + 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 + { + bytesRead = baseStream.ReadAtLeast(hdr, hdr.Length, throwOnEndOfStream: false); + } + + if (bytesRead < hdr.Length) + { + throw new InvalidDataException(SR.TruncatedZipCryptoHeader); + } + + // Decrypt header and update keys + for (int i = 0; i < hdr.Length; i++) + { + byte m = DecryptByte(key2); + byte plain = (byte)(hdr[i] ^ m); + UpdateKeys(ref key0, ref key1, ref key2, plain); + hdr[i] = plain; + } + + if (hdr[11] != expectedCheckByte) + { + throw new InvalidDataException(SR.InvalidPassword); + } + + return (key0, key1, key2); + } + + private static void UpdateKeys(ref uint key0, ref uint key1, ref uint key2, byte b) + { + key0 = Crc32Update(key0, b); + key1 += (key0 & 0xFF); + key1 = key1 * 134775813 + 1; + key2 = Crc32Update(key2, (byte)(key1 >> 24)); + } + + private static byte DecryptByte(uint key2) + { + uint temp = key2 | 2; + return (byte)((temp * (temp ^ 1)) >> 8); + } + + private byte DecryptAndUpdateKeys(byte ciph) + { + byte m = DecryptByte(_key2); + byte plain = (byte)(ciph ^ m); + UpdateKeys(ref _key0, ref _key1, ref _key2, plain); + return plain; + } + + public override bool CanRead => !_disposed && !_encrypting; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed && _encrypting; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _base.Flush(); + } + + 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 destination) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_encrypting) + { + 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(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_encrypting) + { + throw new NotSupportedException(SR.WritingNotSupported); + } + + EnsureHeader(); + + byte[] workBuffer = GetWriteWorkBuffer(); + ReadOnlySpan remaining = buffer; + + while (!remaining.IsEmpty) + { + 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); + } + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + _disposed = true; + + 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); + } + + public override async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + + // 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); + } + + 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. + } + + 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) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_encrypting) + { + 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) + { + ValidateBufferArguments(buffer, offset, count); + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_encrypting) + { + throw new NotSupportedException(SR.WritingNotSupported); + } + + cancellationToken.ThrowIfCancellationRequested(); + + await EnsureHeaderAsync(cancellationToken).ConfigureAwait(false); + + byte[] workBuffer = GetWriteWorkBuffer(); + int offset = 0; + + while (offset < buffer.Length) + { + int chunkSize = Math.Min(buffer.Length - offset, workBuffer.Length); + ReadOnlySpan span = buffer.Span; + + 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) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _base.FlushAsync(cancellationToken); + } + + private byte[] GetWriteWorkBuffer() => _writeWorkBuffer ??= new byte[EncryptionBufferSize]; + } +} 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 new file mode 100644 index 00000000000000..ca2f3d1eadd0c8 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipEncryptionMethod.cs @@ -0,0 +1,42 @@ +// 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 +{ + /// + /// Specifies the encryption method used to encrypt an entry in a zip archive. + /// + 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. + /// + None = 0, + + /// + /// Legacy PKware encryption. + /// + ZipCrypto = 1, + + /// + /// WinZip AES encryption with 128-bit key. + /// + Aes128 = 2, + + /// + /// WinZip AES encryption with 192-bit key. + /// + Aes192 = 3, + + /// + /// WinZip AES encryption with 256-bit key. + /// + Aes256 = 4 + } +} 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 b2d5aaf3524c76..e425efa75a8334 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 @@ -17,6 +17,9 @@ + + + diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs new file mode 100644 index 00000000000000..9291abd1990cbc --- /dev/null +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -0,0 +1,140 @@ +// 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.Runtime.Versioning; +using System.Threading.Tasks; +using Xunit; + +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; + } + + /// + /// Conformance tests for WinZipAesStream (AES-256). + /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + [UnsupportedOSPlatform("browser")] + public sealed class WinZipAes256StreamConformanceTests : WinZipAesStreamConformanceTests + { + protected override int KeySizeBits => 256; + } + + /// + /// Base class for WinZipAesStream conformance tests. + /// + public abstract class WinZipAesStreamConformanceTests : StandaloneStreamConformanceTests + { + private const string TestPassword = "test-password"; + + 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; } + protected int SaltSize => KeySizeBits / 16; + + static WinZipAesStreamConformanceTests() + { + Type winZipAesStreamType = Type.GetType("System.IO.Compression.WinZipAesStream, System.IO.Compression")!; + Type winZipAesKeyMaterialType = Type.GetType("System.IO.Compression.WinZipAesKeyMaterial, System.IO.Compression")!; + + MethodInfo createKeyMethod = winZipAesKeyMaterialType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(ReadOnlySpan), typeof(byte[]), typeof(int) }, + null)!; + + // 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), + 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, winZipAesKeyMaterialType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 + + s_createMethod = winZipAesStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool) }, + null)!; + } + + protected override bool CanSeek => false; + + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => + Task.FromResult(null); // WinZipAesStream is either read-only or write-only + + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) + { + var ms = new MemoryStream(); + object keyMaterial = s_createKey(TestPassword, null, KeySizeBits); + + var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] + { + ms, keyMaterial, -1L, true, false + })!; + + if (initialData != null && initialData.Length > 0) + { + encryptStream.Write(initialData); + } + + return Task.FromResult(encryptStream); + } + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + byte[] plaintext = initialData ?? Array.Empty(); + + // Create key material for encryption (generates random salt) + 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, -1L, true, true + })!) + { + encryptStream.Write(plaintext); + } + + // Extract salt from encrypted data to create matching key material for decryption + byte[] encryptedData = encryptedMs.ToArray(); + byte[] salt = new byte[SaltSize]; + Array.Copy(encryptedData, 0, salt, 0, SaltSize); + + 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, (long)encryptedData.Length, false, false + })!; + + return Task.FromResult(decryptStream); + } + } +} 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..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)) { @@ -919,5 +919,170 @@ 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); + } + + [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); + }); + } } } + diff --git a/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs new file mode 100644 index 00000000000000..b3405643f6c5a1 --- /dev/null +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamConformanceTests.cs @@ -0,0 +1,119 @@ +// 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.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + /// + /// Conformance tests for ZipCryptoStream. + /// + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNativeAot))] + public class ZipCryptoStreamConformanceTests : StandaloneStreamConformanceTests + { + private const string TestPassword = "test-password"; + private const ushort PasswordVerifier = 0x1234; + + private delegate object CreateKeyDelegate(ReadOnlySpan password); + private static readonly CreateKeyDelegate s_createKey; + private static readonly MethodInfo s_createEncryptionMethod; + private static readonly MethodInfo s_createDecryptionMethod; + + static ZipCryptoStreamConformanceTests() + { + Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; + Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; + + 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), + 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, zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 + + s_createEncryptionMethod = zipCryptoStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + null)!; + + s_createDecryptionMethod = zipCryptoStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, + null)!; + + } + + protected override bool CanSeek => false; + + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => + Task.FromResult(null); // ZipCryptoStream is either read-only or write-only + + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) + { + var ms = new MemoryStream(); + object keys = s_createKey(TestPassword); + + var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] + { + ms, keys, PasswordVerifier, true /* encrypting */, null /* crc32 */, false /* leaveOpen */ + })!; + + if (initialData != null && initialData.Length > 0) + { + encryptStream.Write(initialData); + } + + return Task.FromResult(encryptStream); + } + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + byte[] plaintext = initialData ?? Array.Empty(); + object keys = s_createKey(TestPassword); + + // 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, keys, 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(encryptedData); + var decryptStream = (Stream)s_createDecryptionMethod.Invoke(null, new object[] + { + ms, keys, 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 new file mode 100644 index 00000000000000..95beee145ec014 --- /dev/null +++ b/src/libraries/System.IO.Compression/tests/ZipCryptoStreamWrappedConformanceTests.cs @@ -0,0 +1,140 @@ +// 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 Xunit; + +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"; + private const ushort PasswordVerifier = 0x1234; + + 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; + + static ZipCryptoStreamWrappedConformanceTests() + { + Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; + Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; + + 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), + 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, zipCryptoKeysType); + il.Emit(System.Reflection.Emit.OpCodes.Ret); + s_createKey = dm.CreateDelegate(); +#pragma warning restore IL3050 + + s_createEncryptionMethod = zipCryptoStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), zipCryptoKeysType, typeof(ushort), typeof(bool), typeof(uint?), typeof(bool) }, + null)!; + + s_createDecryptionMethod = zipCryptoStreamType.GetMethod("Create", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool) }, + null)!; + + s_createAsyncMethod = zipCryptoStreamType.GetMethod("CreateAsync", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(CancellationToken), typeof(bool) }, + null); + } + + // ZipCryptoStream doesn't support seeking + protected override bool CanSeek => false; + + protected override bool FlushGuaranteesAllDataWritten => true; + + // ZipCrypto uses streaming cipher - blocks on zero byte reads + protected override bool BlocksOnZeroByteReads => true; + + // No concurrent exception type - single-threaded stream + protected override Type UnsupportedConcurrentExceptionType => null!; + + protected override Task CreateConnectedStreamsAsync() + { + // Create bidirectional connected streams with sufficient buffer for header + (Stream stream1, Stream stream2) = ConnectedStreams.CreateBidirectional(4 * 1024, 16 * 1024); + return CreateWrappedConnectedStreamsAsync((stream1, stream2), leaveOpen: false); + } + + protected override async Task CreateWrappedConnectedStreamsAsync(StreamPair wrapped, bool leaveOpen = false) + { + 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 + + // Create the encryption stream (write-only) - wraps stream1 + var encryptStream = (Stream)s_createEncryptionMethod.Invoke(null, new object?[] + { + wrapped.Stream1, keys, PasswordVerifier, true /* encrypting */, null /* crc32 */, leaveOpen + })!; + + // Write and flush the header so the decryption stream can read it + // ZipCrypto header is written lazily on first write, so we need to trigger it + // Use async operations to support AsyncOnlyStream wrappers used by conformance tests + await encryptStream.WriteAsync(new byte[] { 0 }, 0, 1).ConfigureAwait(false); // Trigger header write + await encryptStream.FlushAsync().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 + 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]; + await decryptStream.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); + + return (encryptStream, decryptStream); + } + + 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, keys, expectedCheckByte, false /* encrypting */, CancellationToken.None, leaveOpen + })!; + await task.ConfigureAwait(false); + + // Get the Result property from the completed task +#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)!; + + } + } +} 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 344b9f60f008ed..da1a5c444712c6 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -2069,7 +2069,6 @@ -