Skip to content

Commit f76cbfd

Browse files
Copilotrzikmalinpahontu2912Copilot
authored
Make SubReadStream seekable when underlying stream is seekable (dotnet#118720)
* Initial plan * Implement seekable SubReadStream when underlying stream is seekable Co-authored-by: rzikm <[email protected]> * Fix bounds checking in SubReadStream.Seek and add test for reading entry content twice Co-authored-by: rzikm <[email protected]> * Address PR feedback: fix bounds checking, use localized strings, refactor test Co-authored-by: rzikm <[email protected]> * Fix ReadStreamSeekOps test to allow seeking beyond end of stream Co-authored-by: rzikm <[email protected]> * Add braces to if statements per .editorconfig requirement Co-authored-by: rzikm <[email protected]> * Update ReadStreamOps test to check compression method instead of using size heuristic Co-authored-by: alinpahontu2912 <[email protected]> * Update ReadStreamOps test to dynamically check CanSeek property and test seeking behavior accordingly Co-authored-by: alinpahontu2912 <[email protected]> * Fix SubReadStream seek operations to have immediate observable effects Co-authored-by: alinpahontu2912 <[email protected]> * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Revert immediate observable effects from SubReadStream seek operations Co-authored-by: alinpahontu2912 <[email protected]> * Revert dfd29b2 changes and restore e525dfb immediate observable effects - Reverted the problematic helper method from dfd29b2 that replaced reflection in tests - Restored the e525dfb changes that make SubReadStream seek operations have immediate observable effects - Position setter and Seek method now call _superStream.Seek() immediately rather than deferring - All 1335 tests pass Co-authored-by: alinpahontu2912 <[email protected]> * Honor return value of Seek and use Position setter in Position property Co-authored-by: alinpahontu2912 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: rzikm <[email protected]> Co-authored-by: alinpahontu2912 <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 50c03e2 commit f76cbfd

File tree

4 files changed

+272
-8
lines changed

4 files changed

+272
-8
lines changed

src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Open.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,34 @@ public async Task ReadStreamOps(bool async)
293293
Stream s = await OpenEntryStream(async, e);
294294
Assert.True(s.CanRead, "Can read to read archive");
295295
Assert.False(s.CanWrite, "Can't write to read archive");
296-
Assert.False(s.CanSeek, "Can't seek on archive");
296+
297+
if (s.CanSeek)
298+
{
299+
// If the stream is seekable, verify that seeking works correctly
300+
// Test seeking to beginning
301+
long beginResult = s.Seek(0, SeekOrigin.Begin);
302+
Assert.Equal(0, beginResult);
303+
Assert.Equal(0, s.Position);
304+
305+
// Test seeking to end
306+
long endResult = s.Seek(0, SeekOrigin.End);
307+
Assert.Equal(e.Length, endResult);
308+
Assert.Equal(e.Length, s.Position);
309+
310+
// Test Position setter
311+
s.Position = 0;
312+
Assert.Equal(0, s.Position);
313+
314+
// Reset to beginning for length check
315+
s.Seek(0, SeekOrigin.Begin);
316+
}
317+
else
318+
{
319+
// If the stream is not seekable, verify that seeking throws
320+
Assert.Throws<NotSupportedException>(() => s.Seek(0, SeekOrigin.Begin));
321+
Assert.Throws<NotSupportedException>(() => s.Position = 0);
322+
}
323+
297324
Assert.Equal(await LengthOfUnseekableStream(s), e.Length);
298325
await DisposeStream(async, s);
299326
}

src/libraries/System.IO.Compression/src/Resources/Strings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,4 +296,7 @@
296296
<data name="InvalidOffsetToZip64EOCD" xml:space="preserve">
297297
<value>Invalid offset to the Zip64 End of Central Directory record.</value>
298298
</data>
299+
<data name="IO_SeekBeforeBegin" xml:space="preserve">
300+
<value>An attempt was made to move the position before the beginning of the stream.</value>
301+
</data>
299302
</root>

src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,22 @@ public override long Position
259259
{
260260
ThrowIfDisposed();
261261

262-
throw new NotSupportedException(SR.SeekingNotSupported);
262+
if (!CanSeek)
263+
{
264+
throw new NotSupportedException(SR.SeekingNotSupported);
265+
}
266+
267+
ArgumentOutOfRangeException.ThrowIfNegative(value);
268+
269+
long newPositionInSuperStream = _startInSuperStream + value;
270+
_superStream.Position = newPositionInSuperStream;
271+
_positionInSuperStream = newPositionInSuperStream;
263272
}
264273
}
265274

266275
public override bool CanRead => _superStream.CanRead && _canRead;
267276

268-
public override bool CanSeek => false;
277+
public override bool CanSeek => _superStream.CanSeek && !_isDisposed;
269278

270279
public override bool CanWrite => false;
271280

@@ -366,7 +375,29 @@ async ValueTask<int> Core(Memory<byte> buffer, CancellationToken cancellationTok
366375
public override long Seek(long offset, SeekOrigin origin)
367376
{
368377
ThrowIfDisposed();
369-
throw new NotSupportedException(SR.SeekingNotSupported);
378+
379+
if (!CanSeek)
380+
{
381+
throw new NotSupportedException(SR.SeekingNotSupported);
382+
}
383+
384+
long newPositionInSuperStream = origin switch
385+
{
386+
SeekOrigin.Begin => _startInSuperStream + offset,
387+
SeekOrigin.Current => _positionInSuperStream + offset,
388+
SeekOrigin.End => _endInSuperStream + offset,
389+
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
390+
};
391+
392+
if (newPositionInSuperStream < _startInSuperStream)
393+
{
394+
throw new IOException(SR.IO_SeekBeforeBegin);
395+
}
396+
397+
long actualPositionInSuperStream = _superStream.Seek(newPositionInSuperStream, SeekOrigin.Begin);
398+
_positionInSuperStream = actualPositionInSuperStream;
399+
400+
return _positionInSuperStream - _startInSuperStream;
370401
}
371402

372403
public override void SetLength(long value)

src/libraries/System.IO.Compression/tests/ZipArchive/zip_ReadTests.cs

Lines changed: 207 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,40 @@
1010

1111
namespace System.IO.Compression.Tests
1212
{
13+
// Test utility class that wraps a stream and makes it non-seekable
14+
internal class NonSeekableStream : Stream
15+
{
16+
private readonly Stream _baseStream;
17+
18+
public NonSeekableStream(Stream baseStream)
19+
{
20+
_baseStream = baseStream;
21+
}
22+
23+
public override bool CanRead => _baseStream.CanRead;
24+
public override bool CanSeek => false; // Force non-seekable
25+
public override bool CanWrite => _baseStream.CanWrite;
26+
public override long Length => _baseStream.Length;
27+
public override long Position
28+
{
29+
get => _baseStream.Position;
30+
set => throw new NotSupportedException("Seeking is not supported");
31+
}
32+
33+
public override void Flush() => _baseStream.Flush();
34+
public override int Read(byte[] buffer, int offset, int count) => _baseStream.Read(buffer, offset, count);
35+
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException("Seeking is not supported");
36+
public override void SetLength(long value) => throw new NotSupportedException("SetLength is not supported");
37+
public override void Write(byte[] buffer, int offset, int count) => _baseStream.Write(buffer, offset, count);
38+
39+
protected override void Dispose(bool disposing)
40+
{
41+
if (disposing)
42+
_baseStream.Dispose();
43+
base.Dispose(disposing);
44+
}
45+
}
46+
1347
public class zip_ReadTests : ZipFileTestBase
1448
{
1549
public static IEnumerable<object[]> Get_ReadNormal_Data()
@@ -58,6 +92,17 @@ public static IEnumerable<object[]> Get_TestPartialReads_Data()
5892
}
5993
}
6094

95+
public static IEnumerable<object[]> Get_BooleanCombinations_Data()
96+
{
97+
foreach (bool async in _bools)
98+
{
99+
foreach (bool useSeekMethod in _bools)
100+
{
101+
yield return new object[] { async, useSeekMethod };
102+
}
103+
}
104+
}
105+
61106
[Theory]
62107
[MemberData(nameof(Get_TestPartialReads_Data))]
63108
public static async Task TestPartialReads(string zipFile, string zipFolder, bool async)
@@ -267,8 +312,14 @@ public static async Task ReadModeInvalidOpsTest(bool async)
267312
Stream s = await OpenEntryStream(async, e);
268313
Assert.Throws<NotSupportedException>(() => s.Flush()); //"Should not be able to flush on read stream"
269314
Assert.Throws<NotSupportedException>(() => s.WriteByte(25)); //"should not be able to write to read stream"
270-
Assert.Throws<NotSupportedException>(() => s.Position = 4); //"should not be able to seek on read stream"
271-
Assert.Throws<NotSupportedException>(() => s.Seek(0, SeekOrigin.Begin)); //"should not be able to seek on read stream"
315+
316+
// Seeking behavior depends on whether the entry is compressed and the underlying stream is seekable
317+
if (!s.CanSeek)
318+
{
319+
Assert.Throws<NotSupportedException>(() => s.Position = 4); //"should not be able to seek on non-seekable read stream"
320+
Assert.Throws<NotSupportedException>(() => s.Seek(0, SeekOrigin.Begin)); //"should not be able to seek on non-seekable read stream"
321+
}
322+
272323
Assert.Throws<NotSupportedException>(() => s.SetLength(0)); //"should not be able to resize read stream"
273324

274325
await DisposeZipArchive(async, archive);
@@ -532,21 +583,173 @@ public static async Task ReadStreamOps(bool async)
532583
MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("normal.zip"));
533584
ZipArchive archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read);
534585

586+
FieldInfo compressionMethodField = typeof(ZipArchiveEntry).GetField("_storedCompressionMethod", BindingFlags.NonPublic | BindingFlags.Instance);
587+
535588
foreach (ZipArchiveEntry e in archive.Entries)
536589
{
537590
Stream s = await OpenEntryStream(async, e);
538591

539592
Assert.True(s.CanRead, "Can read to read archive");
540593
Assert.False(s.CanWrite, "Can't write to read archive");
541-
Assert.False(s.CanSeek, "Can't seek on archive");
542-
Assert.Equal(await LengthOfUnseekableStream(s), e.Length); //"Length is not correct on unseekable stream"
594+
595+
// Check the entry's compression method to determine seekability
596+
// SubReadStream should be seekable when the underlying stream is seekable and the entry is stored (uncompressed)
597+
// If the entry is compressed (Deflate, Deflate64, etc.), it will be wrapped in a compression stream which is not seekable
598+
ushort compressionMethod = (ushort)compressionMethodField.GetValue(e);
599+
const ushort StoredCompressionMethod = 0x0; // CompressionMethodValues.Stored
600+
601+
if (compressionMethod == StoredCompressionMethod)
602+
{
603+
// Entry is stored (uncompressed), should be seekable
604+
Assert.True(s.CanSeek, $"SubReadStream should be seekable for stored (uncompressed) entry '{e.FullName}' with compression method {compressionMethod} when underlying stream is seekable");
605+
}
606+
else
607+
{
608+
// Entry is compressed (Deflate, Deflate64, etc.), wrapped in compression stream, should not be seekable
609+
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");
610+
}
611+
612+
Assert.Equal(await LengthOfUnseekableStream(s), e.Length); //"Length is not correct on stream"
543613

544614
await DisposeStream(async, s);
545615
}
546616

547617
await DisposeZipArchive(async, archive);
548618
}
549619

620+
[Theory]
621+
[MemberData(nameof(Get_Booleans_Data))]
622+
public static async Task ReadStreamSeekOps(bool async)
623+
{
624+
// Create a ZIP archive with stored (uncompressed) entries to test SubReadStream seekability
625+
using (var ms = new MemoryStream())
626+
{
627+
// Create a ZIP with stored entries
628+
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
629+
{
630+
var entry = archive.CreateEntry("test.txt", CompressionLevel.NoCompression);
631+
using (var stream = entry.Open())
632+
{
633+
var testData = "This is test data for seeking operations."u8.ToArray();
634+
stream.Write(testData, 0, testData.Length);
635+
}
636+
}
637+
638+
ms.Position = 0;
639+
using (var archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read))
640+
{
641+
foreach (ZipArchiveEntry e in archive.Entries)
642+
{
643+
if (e.Length == 0) continue; // Skip empty entries for this test
644+
645+
Stream s = await OpenEntryStream(async, e);
646+
647+
// For stored entries, SubReadStream should be seekable when underlying stream is seekable
648+
Assert.True(s.CanSeek, $"SubReadStream should be seekable for stored entry '{e.FullName}' when underlying stream is seekable");
649+
650+
// Test seeking to beginning
651+
long pos = s.Seek(0, SeekOrigin.Begin);
652+
Assert.Equal(0, pos);
653+
Assert.Equal(0, s.Position);
654+
655+
// Test seeking to end
656+
pos = s.Seek(0, SeekOrigin.End);
657+
Assert.Equal(e.Length, pos);
658+
Assert.Equal(e.Length, s.Position);
659+
660+
// Test seeking from current position
661+
s.Position = 0;
662+
if (e.Length > 1)
663+
{
664+
pos = s.Seek(1, SeekOrigin.Current);
665+
Assert.Equal(1, pos);
666+
Assert.Equal(1, s.Position);
667+
}
668+
669+
// Test setting position directly
670+
s.Position = 0;
671+
Assert.Equal(0, s.Position);
672+
673+
// Test that seeking before beginning throws, but beyond end is allowed
674+
Assert.Throws<ArgumentOutOfRangeException>(() => s.Position = -1);
675+
Assert.Throws<IOException>(() => s.Seek(-1, SeekOrigin.Begin));
676+
677+
// Seeking beyond end should be allowed (no exception)
678+
s.Position = e.Length + 1;
679+
Assert.Equal(e.Length + 1, s.Position);
680+
s.Seek(1, SeekOrigin.End);
681+
Assert.Equal(e.Length + 1, s.Position);
682+
683+
await DisposeStream(async, s);
684+
}
685+
}
686+
}
687+
}
688+
689+
[Theory]
690+
[MemberData(nameof(Get_BooleanCombinations_Data))]
691+
public static async Task ReadEntryContentTwice(bool async, bool useSeekMethod)
692+
{
693+
// Create a ZIP archive with stored (uncompressed) entries to test reading content twice
694+
using (var ms = new MemoryStream())
695+
{
696+
var testData = "This is test data for reading content twice with seeking operations."u8.ToArray();
697+
698+
// Create a ZIP with stored entries
699+
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
700+
{
701+
var entry = archive.CreateEntry("test.txt", CompressionLevel.NoCompression);
702+
using (var stream = entry.Open())
703+
{
704+
stream.Write(testData, 0, testData.Length);
705+
}
706+
}
707+
708+
ms.Position = 0;
709+
using (var archive = await CreateZipArchive(async, ms, ZipArchiveMode.Read))
710+
{
711+
foreach (ZipArchiveEntry e in archive.Entries)
712+
{
713+
if (e.Length == 0) continue; // Skip empty entries for this test
714+
715+
Stream s = await OpenEntryStream(async, e);
716+
717+
// For stored entries, SubReadStream should be seekable when underlying stream is seekable
718+
Assert.True(s.CanSeek, $"SubReadStream should be seekable for stored entry '{e.FullName}' when underlying stream is seekable");
719+
720+
// Read content first time
721+
byte[] firstRead = new byte[e.Length];
722+
int bytesRead1 = s.Read(firstRead, 0, (int)e.Length);
723+
Assert.Equal(e.Length, bytesRead1);
724+
725+
// Rewind to beginning using specified method
726+
if (useSeekMethod)
727+
{
728+
long pos = s.Seek(0, SeekOrigin.Begin);
729+
Assert.Equal(0, pos);
730+
}
731+
else
732+
{
733+
s.Position = 0;
734+
}
735+
Assert.Equal(0, s.Position);
736+
737+
// Read content second time
738+
byte[] secondRead = new byte[e.Length];
739+
int bytesRead2 = s.Read(secondRead, 0, (int)e.Length);
740+
Assert.Equal(e.Length, bytesRead2);
741+
742+
// Compare the content - should be identical
743+
Assert.Equal(firstRead, secondRead);
744+
Assert.Equal(testData, firstRead);
745+
Assert.Equal(testData, secondRead);
746+
747+
await DisposeStream(async, s);
748+
}
749+
}
750+
}
751+
}
752+
550753
private static byte[] ReverseCentralDirectoryEntries(byte[] zipFile)
551754
{
552755
byte[] destinationBuffer = new byte[zipFile.Length];

0 commit comments

Comments
 (0)