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 6f4c376de6c241..28b2f8a301c270 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -127,6 +127,7 @@ internal ZipArchiveEntry() { } 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 override string ToString() { 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 d477d0c40f6624..5fd5e9e3cedc88 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -296,6 +296,15 @@ Invalid offset to the Zip64 End of Central Directory record. + + Cannot open entry for writing when archive is opened in read-only mode. + + + Cannot open entry for reading when archive is opened in create mode. + + + The specified FileAccess value is not valid. + An attempt was made to move the position before the beginning of the stream. diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index c382c5e2c840c5..365abe5e2b3a08 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 @@ -378,6 +378,48 @@ public Stream Open() } } + /// + /// Opens the entry with the specified access mode. This allows for more granular control over the returned stream's capabilities. + /// + /// The file access mode for the returned stream. + /// A that represents the contents of the entry with the specified access capabilities. + /// 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. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. + /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. + /// The ZipArchive that this entry belongs to has been disposed. + public Stream Open(FileAccess access) + { + ThrowIfInvalidArchive(); + + if (access is not FileAccess.Read and not FileAccess.Write and not FileAccess.ReadWrite) + throw new ArgumentException(SR.InvalidFileAccess, nameof(access)); + + // Validate that the requested access is compatible with the archive's mode + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + if (access != FileAccess.Read) + throw new ArgumentException(SR.CannotBeWrittenInReadMode, nameof(access)); + return OpenInReadMode(checkOpenable: true); + + case ZipArchiveMode.Create: + if (access == FileAccess.Read) + throw new ArgumentException(SR.CannotBeReadInCreateMode, nameof(access)); + return OpenInWriteMode(); + + case ZipArchiveMode.Update: + default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + return access switch + { + FileAccess.Read => OpenInReadMode(checkOpenable: true), + FileAccess.Write => OpenInWriteModeForUpdate(), + FileAccess.ReadWrite => OpenInUpdateMode(), + _ => throw new UnreachableException() + }; + } + } + /// /// Returns the FullName of the entry. /// @@ -800,6 +842,36 @@ private WrappedStream OpenInWriteMode() // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded _archive.DebugAssertIsStillArchiveStreamOwner(this); + return OpenInWriteModeCore(); + } + + private WrappedStream OpenInWriteModeForUpdate() + { + if (_currentlyOpenForWrite) + throw new IOException(SR.UpdateModeOneStream); + + // Write access in Update mode means "replace the entry content entirely". + // We provide an empty MemoryStream (discarding any existing data) and write + // the new content to the archive at dispose time. This is necessary because + // writing directly to the archive could overwrite the next entry if the new + // data is larger than the original. + _everOpenedForWrite = true; + Changes |= ZipArchive.ChangeState.StoredData; + _currentlyOpenForWrite = true; + + // Create a fresh empty MemoryStream, discarding any previously loaded data + _storedUncompressedData = new MemoryStream(); + + // Return a stream wrapper. The stream is writable and seekable (like MemoryStream), + // but starts empty unlike ReadWrite mode which loads existing data. + return new WrappedStream(_storedUncompressedData, this, thisRef => + { + thisRef!._currentlyOpenForWrite = false; + }); + } + + private WrappedStream OpenInWriteModeCore() + { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs index d78f2d62432fb7..4c9d7e0c056586 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs @@ -2189,5 +2189,362 @@ private static byte[] GenerateInvalidExtraFieldData(byte modifiedVersionToExtrac // comment length 0x00, 0x00 }; + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_ReadMode_ReadAccess_Succeeds(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + using Stream stream = entry.Open(FileAccess.Read); + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_ReadMode_WriteAccess_Throws(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + Assert.Throws("access", () => entry.Open(FileAccess.Write)); + Assert.Throws("access", () => entry.Open(FileAccess.ReadWrite)); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_CreateMode_WriteAccess_Succeeds(bool async) + { + using var ms = new MemoryStream(); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Create, leaveOpen: true); + + ZipArchiveEntry entry = archive.CreateEntry("test.txt"); + + using Stream stream = entry.Open(FileAccess.Write); + Assert.False(stream.CanRead); + Assert.True(stream.CanWrite); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_CreateMode_ReadWriteAccess_Succeeds(bool async) + { + using var ms = new MemoryStream(); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Create, leaveOpen: true); + + ZipArchiveEntry entry = archive.CreateEntry("test.txt"); + + // ReadWrite should be allowed in Create mode (it opens in write mode) + using Stream stream = entry.Open(FileAccess.ReadWrite); + Assert.True(stream.CanWrite); + Assert.False(stream.CanRead); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_CreateMode_ReadAccess_Throws(bool async) + { + using var ms = new MemoryStream(); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Create, leaveOpen: true); + + ZipArchiveEntry entry = archive.CreateEntry("test.txt"); + + Assert.Throws("access", () => entry.Open(FileAccess.Read)); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_ReadAccess_Succeeds(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + using Stream stream = entry.Open(FileAccess.Read); + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_WriteAccess_Succeeds(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update); + + ZipArchiveEntry entry = archive.CreateEntry("new_entry.txt"); + + // In Update mode, FileAccess.Write provides an empty stream (discarding any existing data). + // The stream is backed by a MemoryStream, so it supports read/write/seek, but starts empty. + using Stream stream = entry.Open(FileAccess.Write); + Assert.True(stream.CanWrite); + Assert.True(stream.CanRead); + Assert.True(stream.CanSeek); + Assert.Equal(0, stream.Length); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_WriteAccess_CanWriteAndReadBack(bool async) + { + const string entryName = "new_entry.txt"; + const string testContent = "Hello, World!"; + byte[] testData = System.Text.Encoding.UTF8.GetBytes(testContent); + + using MemoryStream ms = new MemoryStream(); + + // Create archive with an entry using FileAccess.Write + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update, leaveOpen: true); + ZipArchiveEntry entry = archive.CreateEntry(entryName); + + using (Stream stream = entry.Open(FileAccess.Write)) + { + stream.Write(testData, 0, testData.Length); + } + + await DisposeZipArchive(async, archive); + + // Re-open archive and verify the entry can be read back + ms.Position = 0; + ZipArchive readArchive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry readEntry = readArchive.GetEntry(entryName); + Assert.NotNull(readEntry); + + using (Stream readStream = readEntry.Open()) + using (StreamReader reader = new StreamReader(readStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal(testContent, content); + } + + await DisposeZipArchive(async, readArchive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_WriteAccess_ExistingEntry_DiscardsOldData(bool async) + { + const string entryName = "first.txt"; + const string newContent = "New content replaces old"; + byte[] newData = System.Text.Encoding.UTF8.GetBytes(newContent); + + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + + // Open in Update mode and overwrite existing entry with FileAccess.Write + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update, leaveOpen: true); + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(FileAccess.Write)) + { + // Stream should be empty - existing data is discarded + Assert.Equal(0, stream.Length); + stream.Write(newData, 0, newData.Length); + } + + await DisposeZipArchive(async, archive); + + // Re-open and verify the entry contains only the new content + ms.Position = 0; + ZipArchive readArchive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry readEntry = readArchive.GetEntry(entryName); + Assert.NotNull(readEntry); + + using (Stream readStream = readEntry.Open()) + using (StreamReader reader = new StreamReader(readStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal(newContent, content); + } + + await DisposeZipArchive(async, readArchive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_ReadWriteAccess_Succeeds(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + using Stream stream = entry.Open(FileAccess.ReadWrite); + Assert.True(stream.CanRead); + Assert.True(stream.CanWrite); + Assert.True(stream.CanSeek); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_ReadMode_InvalidAccess_Throws(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + // Test with invalid FileAccess values + Assert.Throws("access", () => entry.Open((FileAccess)0)); + Assert.Throws("access", () => entry.Open((FileAccess)4)); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_CreateMode_InvalidAccess_Throws(bool async) + { + using var ms = new MemoryStream(); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Create, leaveOpen: true); + + ZipArchiveEntry entry = archive.CreateEntry("test.txt"); + + // Test with invalid FileAccess values + Assert.Throws("access", () => entry.Open((FileAccess)0)); + Assert.Throws("access", () => entry.Open((FileAccess)4)); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_UpdateMode_InvalidAccess_Throws(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Update); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + // Test with invalid FileAccess values + Assert.Throws("access", () => entry.Open((FileAccess)0)); + Assert.Throws("access", () => entry.Open((FileAccess)4)); + + await DisposeZipArchive(async, archive); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_MatchesParameterlessOpen_ReadMode(bool async) + { + using MemoryStream ms1 = await StreamHelpers.CreateTempCopyStream(zfile("small.zip")); + using MemoryStream ms2 = await StreamHelpers.CreateTempCopyStream(zfile("small.zip")); + + ZipArchive archive1 = await CreateZipArchive(async, ms1, ZipArchiveMode.Read); + ZipArchive archive2 = await CreateZipArchive(async, ms2, ZipArchiveMode.Read); + + ZipArchiveEntry entry1 = archive1.Entries[0]; + ZipArchiveEntry entry2 = archive2.Entries[0]; + + byte[] contents1, contents2; + + using (Stream stream1 = entry1.Open()) + { + using var reader = new MemoryStream(); + stream1.CopyTo(reader); + contents1 = reader.ToArray(); + } + + using (Stream stream2 = entry2.Open(FileAccess.Read)) + { + using var reader = new MemoryStream(); + stream2.CopyTo(reader); + contents2 = reader.ToArray(); + } + + Assert.Equal(contents1, contents2); + + await DisposeZipArchive(async, archive1); + await DisposeZipArchive(async, archive2); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_MatchesParameterlessOpen_UpdateMode(bool async) + { + using MemoryStream ms1 = await StreamHelpers.CreateTempCopyStream(zfile("small.zip")); + using MemoryStream ms2 = await StreamHelpers.CreateTempCopyStream(zfile("small.zip")); + + ZipArchive archive1 = await CreateZipArchive(async, ms1, ZipArchiveMode.Update); + ZipArchive archive2 = await CreateZipArchive(async, ms2, ZipArchiveMode.Update); + + ZipArchiveEntry entry1 = archive1.Entries[0]; + ZipArchiveEntry entry2 = archive2.Entries[0]; + + byte[] contents1, contents2; + + using (Stream stream1 = entry1.Open()) + { + Assert.True(stream1.CanRead); + Assert.True(stream1.CanWrite); + Assert.True(stream1.CanSeek); + + using var reader = new MemoryStream(); + stream1.CopyTo(reader); + contents1 = reader.ToArray(); + } + + using (Stream stream2 = entry2.Open(FileAccess.ReadWrite)) + { + Assert.True(stream2.CanRead); + Assert.True(stream2.CanWrite); + Assert.True(stream2.CanSeek); + + using var reader = new MemoryStream(); + stream2.CopyTo(reader); + contents2 = reader.ToArray(); + } + + Assert.Equal(contents1, contents2); + + await DisposeZipArchive(async, archive1); + await DisposeZipArchive(async, archive2); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task OpenWithFileAccess_DisposedArchive_Throws(bool async) + { + using MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip")); + ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read); + + ZipArchiveEntry entry = archive.GetEntry("first.txt"); + Assert.NotNull(entry); + + await DisposeZipArchive(async, archive); + + Assert.Throws(() => entry.Open(FileAccess.Read)); + } } }