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 a3a6c6f6c78ce3..c75b57290412c4 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 @@ -147,10 +147,24 @@ protected virtual async ValueTask DisposeAsyncCore() case ZipArchiveMode.Read: break; case ZipArchiveMode.Create: + await WriteFileAsync().ConfigureAwait(false); + break; case ZipArchiveMode.Update: default: - Debug.Assert(_mode == ZipArchiveMode.Update || _mode == ZipArchiveMode.Create); - await WriteFileAsync().ConfigureAwait(false); + Debug.Assert(_mode == ZipArchiveMode.Update); + // Only write if the archive has been modified + if (IsModified) + { + await WriteFileAsync().ConfigureAwait(false); + } + else + { + // Even if we didn't write, unload any entry buffers that may have been loaded + foreach (ZipArchiveEntry entry in _entries) + { + await entry.UnloadStreamsAsync().ConfigureAwait(false); + } + } break; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 6e1e36fd63b8eb..8d9e5fe6214fee 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 @@ -297,10 +297,24 @@ protected virtual void Dispose(bool disposing) case ZipArchiveMode.Read: break; case ZipArchiveMode.Create: + WriteFile(); + break; case ZipArchiveMode.Update: default: - Debug.Assert(_mode == ZipArchiveMode.Update || _mode == ZipArchiveMode.Create); - WriteFile(); + Debug.Assert(_mode == ZipArchiveMode.Update); + // Only write if the archive has been modified + if (IsModified) + { + WriteFile(); + } + else + { + // Even if we didn't write, unload any entry buffers that may have been loaded + foreach (ZipArchiveEntry entry in _entries) + { + entry.UnloadStreams(); + } + } break; } } @@ -311,7 +325,6 @@ protected virtual void Dispose(bool disposing) } } } - /// /// Finishes writing the archive and releases all resources used by the ZipArchive object, unless the object was constructed with leaveOpen as true. Any streams from opened entries in the ZipArchive still open will throw exceptions on subsequent writes, as the underlying streams will have been closed. /// @@ -379,6 +392,39 @@ private set // New entries in the archive won't change its state. internal ChangeState Changed { get; private set; } + /// + /// Determines whether the archive has been modified and needs to be written. + /// + private bool IsModified + { + get + { + // A new archive (created on empty stream) always needs to write the structure + if (_archiveStream.Length == 0) + { + return true; + } + // Archive-level changes (e.g., comment) + if (Changed != ChangeState.Unchanged) + { + return true; + } + // Any deleted entries + if (_firstDeletedEntryOffset != long.MaxValue) + { + return true; + } + // Check if any entry was modified or added + foreach (ZipArchiveEntry entry in _entries) + { + if (!entry.OriginallyInArchive || entry.Changes != ChangeState.Unchanged) + return true; + } + + return false; + } + } + private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel) { ArgumentException.ThrowIfNullOrEmpty(entryName); 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 4a540ad7679611..52252360711f11 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 @@ -143,12 +143,6 @@ private async Task GetUncompressedDataAsync(CancellationToken canc } } } - - // if they start modifying it and the compression method is not "store", we should make sure it will get deflated - if (CompressionMethod != ZipCompressionMethod.Stored) - { - CompressionMethod = ZipCompressionMethod.Deflate; - } } return _storedUncompressedData; @@ -270,8 +264,6 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); } - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; _currentlyOpenForWrite = true; if (loadExistingContent) @@ -282,14 +274,15 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent { _storedUncompressedData?.Dispose(); _storedUncompressedData = new MemoryStream(); + // Opening with loadExistingContent: false discards existing content, which is a modification + MarkAsModified(); } _storedUncompressedData.Seek(0, SeekOrigin.Begin); - return new WrappedStream(_storedUncompressedData, this, thisRef => - { - thisRef!._currentlyOpenForWrite = false; - }); + return new WrappedStream(_storedUncompressedData, this, + onClosed: thisRef => thisRef!._currentlyOpenForWrite = false, + notifyEntryOnWrite: true); } private async Task<(bool, string?)> IsOpenableAsync(bool needToUncompress, bool needToLoadIntoMemory, CancellationToken cancellationToken) @@ -351,6 +344,19 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + + // 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 + // was opened for update but no writes occurred - we should use the original compressed bytes. + bool storedDataModified = (Changes & ZipArchive.ChangeState.StoredData) != 0; + + // If _storedUncompressedData is loaded but not modified, clear it so we use _compressedBytes + if (_storedUncompressedData != null && !storedDataModified) + { + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + // _storedUncompressedData gets frozen here, and is what gets written to the file if (_storedUncompressedData != null || _compressedBytes != null) { @@ -466,7 +472,7 @@ private ValueTask WriteDataDescriptorAsync(CancellationToken cancellationToken) return _archive.ArchiveStream.WriteAsync(dataDescriptor.AsMemory(0, bytesToWrite), cancellationToken); } - private async Task UnloadStreamsAsync() + internal async Task UnloadStreamsAsync() { if (_storedUncompressedData != null) { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 0e077b9dc25708..fb9a34cc23ffac 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 @@ -508,16 +508,15 @@ private MemoryStream GetUncompressedData() } } - // if they start modifying it and the compression method is not "store", we should make sure it will get deflated - if (CompressionMethod != ZipCompressionMethod.Stored) - { - CompressionMethod = ZipCompressionMethod.Deflate; - } + // NOTE: CompressionMethod normalization is deferred to MarkAsModified() to avoid + // corrupting entries that are opened in Update mode but not actually written to. + // If we normalized here and the entry wasn't modified, we'd write a header with + // CompressionMethod=Deflate but the original _compressedBytes would still be in + // their original format (e.g., Deflate64), producing an invalid entry. } return _storedUncompressedData; } - // does almost everything you need to do to forget about this entry // writes the local header/data, gets rid of all the data, // closes all of the streams except for the very outermost one that @@ -882,8 +881,6 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); } - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; _currentlyOpenForWrite = true; if (loadExistingContent) @@ -894,14 +891,31 @@ private WrappedStream OpenInUpdateMode(bool loadExistingContent = true) { _storedUncompressedData?.Dispose(); _storedUncompressedData = new MemoryStream(); + // Opening with loadExistingContent: false discards existing content, which is a modification + MarkAsModified(); } _storedUncompressedData.Seek(0, SeekOrigin.Begin); - return new WrappedStream(_storedUncompressedData, this, thisRef => + return new WrappedStream(_storedUncompressedData, this, + onClosed: thisRef => thisRef!._currentlyOpenForWrite = false, + notifyEntryOnWrite: true); + } + + /// + /// Marks this entry as modified, indicating that its data has changed and needs to be rewritten. + /// + internal void MarkAsModified() + { + _everOpenedForWrite = true; + Changes |= ZipArchive.ChangeState.StoredData; + + // Normalize compression method when actually modifying - Deflate64 data will be + // re-compressed as Deflate since we don't support writing Deflate64. + if (CompressionMethod != ZipCompressionMethod.Stored) { - thisRef!._currentlyOpenForWrite = false; - }); + CompressionMethod = ZipCompressionMethod.Deflate; + } } private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) @@ -1171,6 +1185,18 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) private 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 + // was opened for update but no writes occurred - we should use the original compressed bytes. + bool storedDataModified = (Changes & ZipArchive.ChangeState.StoredData) != 0; + + // If _storedUncompressedData is loaded but not modified, clear it so we use _compressedBytes + if (_storedUncompressedData != null && !storedDataModified) + { + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + // _storedUncompressedData gets frozen here, and is what gets written to the file if (_storedUncompressedData != null || _compressedBytes != null) { @@ -1396,7 +1422,7 @@ private int PrepareToWriteDataDescriptor(Span dataDescriptor) return bytesToWrite; } - private void UnloadStreams() + internal void UnloadStreams() { _storedUncompressedData?.Dispose(); _compressedBytes = null; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index 0514bea7dac946..12f41c5740b8cb 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -16,24 +16,28 @@ internal sealed class WrappedStream : Stream // Delegate that will be invoked on stream disposing private readonly Action? _onClosed; + // When true, notifies the entry on first write operation + private bool _notifyEntryOnWrite; + // Instance that will be passed to _onClose delegate private readonly ZipArchiveEntry? _zipArchiveEntry; private bool _isDisposed; internal WrappedStream(Stream baseStream, bool closeBaseStream) - : this(baseStream, closeBaseStream, null, null) { } + : this(baseStream, closeBaseStream, entry: null, onClosed: null, notifyEntryOnWrite: false) { } - private WrappedStream(Stream baseStream, bool closeBaseStream, ZipArchiveEntry? entry, Action? onClosed) + private WrappedStream(Stream baseStream, bool closeBaseStream, ZipArchiveEntry? entry, Action? onClosed, bool notifyEntryOnWrite) { _baseStream = baseStream; _closeBaseStream = closeBaseStream; _onClosed = onClosed; + _notifyEntryOnWrite = notifyEntryOnWrite; _zipArchiveEntry = entry; _isDisposed = false; } - internal WrappedStream(Stream baseStream, ZipArchiveEntry entry, Action? onClosed) - : this(baseStream, false, entry, onClosed) { } + internal WrappedStream(Stream baseStream, ZipArchiveEntry entry, Action? onClosed, bool notifyEntryOnWrite = false) + : this(baseStream, false, entry, onClosed, notifyEntryOnWrite) { } public override long Length { @@ -144,6 +148,7 @@ public override void SetLength(long value) ThrowIfCantSeek(); ThrowIfCantWrite(); + NotifyWrite(); _baseStream.SetLength(value); } @@ -152,6 +157,7 @@ public override void Write(byte[] buffer, int offset, int count) ThrowIfDisposed(); ThrowIfCantWrite(); + NotifyWrite(); _baseStream.Write(buffer, offset, count); } @@ -160,6 +166,7 @@ public override void Write(ReadOnlySpan source) ThrowIfDisposed(); ThrowIfCantWrite(); + NotifyWrite(); _baseStream.Write(source); } @@ -168,6 +175,7 @@ public override void WriteByte(byte value) ThrowIfDisposed(); ThrowIfCantWrite(); + NotifyWrite(); _baseStream.WriteByte(value); } @@ -176,6 +184,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati ThrowIfDisposed(); ThrowIfCantWrite(); + NotifyWrite(); return _baseStream.WriteAsync(buffer, offset, count, cancellationToken); } @@ -184,9 +193,19 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo ThrowIfDisposed(); ThrowIfCantWrite(); + NotifyWrite(); return _baseStream.WriteAsync(buffer, cancellationToken); } + private void NotifyWrite() + { + if (_notifyEntryOnWrite) + { + _zipArchiveEntry?.MarkAsModified(); + _notifyEntryOnWrite = false; // Only notify once + } + } + public override void Flush() { ThrowIfDisposed(); 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 254d7d07b0bc84..e80fc63790f938 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_InvalidParametersAndStrangeFiles.cs @@ -824,8 +824,7 @@ public static IEnumerable EmptyFiles() /// 2. EmptyFileCompressedWrongSize has /// Deflate 0x08, _uncompressedSize 0, _compressedSize 4, compressed data: 0xBAAD0300 (just bad data) /// ZipArchive is not expected to make any changes to the compression method of an archive entry unless - /// it's been changed. If it has been changed, ZipArchive is expected to change compression method to - /// Stored (0x00) and ignore "bad" compressed size + /// it's been modified. Opening an entry stream without writing does not trigger a rewrite. /// [Theory] [MemberData(nameof(EmptyFiles))] @@ -852,30 +851,43 @@ public async Task ReadArchive_WithEmptyDeflatedFile(byte[] fileBytes, bool async zip = await CreateZipArchive(async, testStream, ZipArchiveMode.Update, leaveOpen: true); var zipEntryStream = await OpenEntryStream(async, zip.Entries[0]); - // dispose after opening an entry will rewrite the archive + // Opening and disposing an entry stream without writing does not mark the archive as modified, + // so no rewrite will occur await DisposeStream(async, zipEntryStream); await DisposeZipArchive(async, zip); fileContent = testStream.ToArray(); - // compression method should change to "uncompressed" (Stored = 0x0) - Assert.Equal(0, fileContent[8]); + // compression method should remain unchanged since we didn't write anything + Assert.Equal(firstEntryCompressionMethod, fileContent[8]); - // extract and check the file. should stay empty. + testStream.Seek(0, SeekOrigin.Begin); + // extract and check the file zip = await CreateZipArchive(async, testStream, ZipArchiveMode.Update); ZipArchiveEntry entry = zip.GetEntry(ExpectedFileName); + // The entry retains its original properties since it was never modified Assert.Equal(0, entry.Length); - Assert.Equal(0, entry.CompressedLength); - Stream entryStream = await OpenEntryStream(async, entry); - Assert.Equal(0, entryStream.Length); - await DisposeStream(async, entryStream); + var extractedEntryStream = await OpenEntryStream(async, entry); + byte[] buffer = new byte[1]; + int bytesRead; + if (async) + { + bytesRead = await extractedEntryStream.ReadAsync(buffer, 0, buffer.Length); + } + else + { + bytesRead = extractedEntryStream.Read(buffer, 0, buffer.Length); + } + + Assert.Equal(0, bytesRead); + + await DisposeStream(async, extractedEntryStream); await DisposeZipArchive(async, zip); } } - /// /// Opens an empty file that has a 64KB EOCD comment. /// Adds two 64KB text entries. Verifies they can be read correctly. diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_UpdateTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_UpdateTests.cs index 827e5f331124a1..bc7ac9facaabd6 100644 --- a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_UpdateTests.cs +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_UpdateTests.cs @@ -330,13 +330,19 @@ public static async Task UpdateModeInvalidOperations(bool async) ZipArchiveEntry edeleted = target.GetEntry("first.txt"); + // Record original values before opening + long originalLength = edeleted.Length; + long originalCompressedLength = edeleted.CompressedLength; + Stream s = await OpenEntryStream(async, edeleted); //invalid ops while entry open await Assert.ThrowsAsync(() => OpenEntryStream(async, edeleted)); - Assert.Throws(() => { var x = edeleted.Length; }); - Assert.Throws(() => { var x = edeleted.CompressedLength; }); + // Length and CompressedLength should still be accessible while stream is open but no writes occurred + Assert.Equal(originalLength, edeleted.Length); + Assert.Equal(originalCompressedLength, edeleted.CompressedLength); + Assert.Throws(() => edeleted.Delete()); await DisposeStream(async, s); @@ -344,8 +350,9 @@ public static async Task UpdateModeInvalidOperations(bool async) //invalid ops on stream after entry closed Assert.Throws(() => s.ReadByte()); - Assert.Throws(() => { var x = edeleted.Length; }); - Assert.Throws(() => { var x = edeleted.CompressedLength; }); + // Length and CompressedLength should still be accessible after stream closed without writes + Assert.Equal(originalLength, edeleted.Length); + Assert.Equal(originalCompressedLength, edeleted.CompressedLength); edeleted.Delete(); @@ -367,6 +374,34 @@ public static async Task UpdateModeInvalidOperations(bool async) Assert.Throws(() => { e.LastWriteTime = new DateTimeOffset(); }); } + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public static async Task UpdateModeInvalidOperations_AfterWrite(bool async) + { + using LocalMemoryStream ms = await LocalMemoryStream.ReadAppFileAsync(zfile("normal.zip")); + + ZipArchive target = await CreateZipArchive(async, ms, ZipArchiveMode.Update, true); + + ZipArchiveEntry entry = target.GetEntry("first.txt"); + + Stream s = await OpenEntryStream(async, entry); + + // Write to the stream - this should mark the entry as modified + s.WriteByte(42); + + // After writing, Length and CompressedLength should throw + Assert.Throws(() => { var x = entry.Length; }); + Assert.Throws(() => { var x = entry.CompressedLength; }); + + await DisposeStream(async, s); + + // After stream is closed with writes, Length and CompressedLength should still throw + Assert.Throws(() => { var x = entry.Length; }); + Assert.Throws(() => { var x = entry.CompressedLength; }); + + await DisposeZipArchive(async, target); + } + [Theory] [MemberData(nameof(Get_Booleans_Data))] public async Task UpdateUncompressedArchive(bool async) @@ -1254,5 +1289,59 @@ public async Task Update_PerformMinimalWritesWhenEntriesModifiedAndAdded_Async(i } } } + + /// + /// Tests that opening an entry stream and disposing it without writing does not mark the archive as modified, + /// thus not triggering a rewrite on Dispose. + /// + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Update_OpenEntryWithoutWriting_DoesNotTriggerRewrite(bool async) + { + // Create a valid zip file + byte[] sampleEntryContents = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + byte[] sampleZipFile = await CreateZipFile(3, sampleEntryContents, async); + long originalLength = sampleZipFile.Length; + + // Keep a copy of the original contents to verify no in-place rewrite occurred + byte[] originalContents = new byte[sampleZipFile.Length]; + Array.Copy(sampleZipFile, originalContents, sampleZipFile.Length); + + // Use a non-expandable MemoryStream (fixed buffer) + // This would throw NotSupportedException if Dispose tries to write/grow the stream + using (MemoryStream ms = new MemoryStream(sampleZipFile, writable: true)) + { + ZipArchive archive = async + ? await ZipArchive.CreateAsync(ms, ZipArchiveMode.Update, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(ms, ZipArchiveMode.Update, leaveOpen: true); + + // Open an entry and read it without writing + ZipArchiveEntry entry = archive.Entries[0]; + Stream entryStream = async ? await entry.OpenAsync() : entry.Open(); + byte[] buffer = new byte[sampleEntryContents.Length + 1]; // +1 for the index byte added by CreateZipFile + int bytesRead = async + ? await entryStream.ReadAsync(buffer) + : entryStream.Read(buffer, 0, buffer.Length); + Assert.InRange(bytesRead, 1, buffer.Length); + + // Close the entry stream without writing anything + if (async) + await entryStream.DisposeAsync(); + else + entryStream.Dispose(); + + // Dispose should not throw NotSupportedException because no writes occurred + // and the archive should not try to rewrite the stream + if (async) + await archive.DisposeAsync(); + else + archive.Dispose(); + + // Verify the stream was not modified - neither length nor contents + Assert.Equal(originalLength, ms.Length); + Assert.Equal(originalContents, sampleZipFile); + } + } + } } diff --git a/src/libraries/System.IO.Packaging/tests/Tests.cs b/src/libraries/System.IO.Packaging/tests/Tests.cs index 818d87e92407a4..549b21843463c4 100644 --- a/src/libraries/System.IO.Packaging/tests/Tests.cs +++ b/src/libraries/System.IO.Packaging/tests/Tests.cs @@ -3961,6 +3961,48 @@ public void Roundtrip_Compression_Option(CompressionOption createdCompressionOpt } } + [Fact] + public void Package_OpenOrCreate_ReadEntryWithoutWrite_DoesNotThrowOnDispose() + { + // Regression test: Opening a package with OpenOrCreate/ReadWrite on a non-expandable + // MemoryStream, then reading an entry without writing, should not throw on Dispose. + // Previously, the ZipArchive would attempt to rewrite even when no changes were made. + + // First, create a valid package + byte[] packageData; + using (var ms = new MemoryStream()) + { + using (Package package = Package.Open(ms, FileMode.Create, FileAccess.ReadWrite)) + { + var partUri = PackUriHelper.CreatePartUri(new Uri("test.xml", UriKind.Relative)); + PackagePart part = package.CreatePart(partUri, Mime_MediaTypeNames_Text_Xml); + using (Stream partStream = part.GetStream()) + using (StreamWriter sw = new StreamWriter(partStream)) + { + sw.Write(s_DocumentXml); + } + } + packageData = ms.ToArray(); + } + + // Create a non-expandable MemoryStream (fixed-size buffer) + byte[] originalData = (byte[])packageData.Clone(); + var stream = new MemoryStream(packageData, writable: true); + + // This should not throw - opening and disposing without changes + using (Package package = Package.Open(stream, FileMode.OpenOrCreate, FileAccess.ReadWrite)) + { + // Just access parts without modifying + var parts = package.GetParts(); + Assert.NotEmpty(parts); + } + + // Verify the stream was not modified (no rewrite occurred) + Assert.Equal(originalData.Length, stream.Length); + Assert.True(originalData.AsSpan().SequenceEqual(packageData), + "Stream content should be unchanged when no modifications were made"); + } + [Fact] public void Cannot_Modify_Package_On_Unseekable_Stream() {