diff --git a/.changes/nextrelease/fix-eventstream-partial-read.json b/.changes/nextrelease/fix-eventstream-partial-read.json new file mode 100644 index 0000000000..e3afe80996 --- /dev/null +++ b/.changes/nextrelease/fix-eventstream-partial-read.json @@ -0,0 +1,7 @@ +[ + { + "type": "bugfix", + "category": "", + "description": "Fixed an issue in NonSeekableStreamDecodingEventStreamIterator where partial reads from non-seekable streams could result in truncated payloads and CRC mismatches." + } +] \ No newline at end of file diff --git a/src/Api/Parser/NonSeekableStreamDecodingEventStreamIterator.php b/src/Api/Parser/NonSeekableStreamDecodingEventStreamIterator.php index ca5388db66..d59f200e4a 100644 --- a/src/Api/Parser/NonSeekableStreamDecodingEventStreamIterator.php +++ b/src/Api/Parser/NonSeekableStreamDecodingEventStreamIterator.php @@ -64,10 +64,21 @@ protected function readAndHashBytes($num): string while (!empty($this->tempBuffer) && $num > 0) { $byte = array_shift($this->tempBuffer); $bytes .= $byte; - $num = $num - 1; + $num -= 1; + } + + // Loop until we've read the expected number of bytes + while ($num > 0 && !$this->stream->eof()) { + $chunk = $this->stream->read($num); + $chunkLen = strlen($chunk); + $bytes .= $chunk; + $num -= $chunkLen; + + if ($chunkLen === 0) { + break; // Prevent infinite loop on unexpected EOF + } } - $bytes = $bytes . $this->stream->read($num); hash_update($this->hashContext, $bytes); return $bytes; diff --git a/tests/Api/Parser/NonSeekableStreamDecodingEventStreamIteratorTest.php b/tests/Api/Parser/NonSeekableStreamDecodingEventStreamIteratorTest.php index 5a79d081b7..d26fbe5dbb 100644 --- a/tests/Api/Parser/NonSeekableStreamDecodingEventStreamIteratorTest.php +++ b/tests/Api/Parser/NonSeekableStreamDecodingEventStreamIteratorTest.php @@ -5,6 +5,7 @@ use Aws\Api\Parser\NonSeekableStreamDecodingEventStreamIterator; use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\StreamInterface; use Yoast\PHPUnitPolyfills\TestCases\TestCase; class NonSeekableStreamDecodingEventStreamIteratorTest extends TestCase @@ -67,4 +68,132 @@ public function testValidReturnsTrueOnEOF() $iterator->next(); $this->assertFalse($iterator->valid()); } + + public function testReadAndHashBytesHandlesPartialReads() + { + $payload = str_repeat('A', 1024); + $stream = new NoSeekStream(new PartialReadStream($payload)); + + $iterator = new NonSeekableStreamDecodingEventStreamIterator($stream); + + $reflect = new \ReflectionClass($iterator); + $hashContextProperty = $reflect->getProperty('hashContext'); + $hashContextProperty->setAccessible(true); + $hashContextProperty->setValue($iterator, hash_init('crc32c')); + + $method = $reflect->getMethod('readAndHashBytes'); + $method->setAccessible(true); + + $result = $method->invoke($iterator, 1024); + + $this->assertEquals($payload, $result); + } } +/** + * Simulates partial reads by limiting each read() call to a maximum number of bytes, + * regardless of what is requested. + */ +class PartialReadStream implements StreamInterface +{ + private string $data; + private int $position = 0; + private int $maxBytesPerRead; + + public function __construct(string $data, int $maxBytesPerRead = 100) + { + $this->data = $data; + $this->maxBytesPerRead = $maxBytesPerRead; + } + + public function __toString(): string + { + return $this->data; + } + + public function close(): void + { + // No resources to close + } + + public function detach() + { + return null; + } + + public function getSize(): ?int + { + return strlen($this->data); + } + + public function tell(): int + { + return $this->position; + } + + public function eof(): bool + { + return $this->position >= strlen($this->data); + } + + public function isSeekable(): bool + { + return false; + } + + public function seek($offset, $whence = SEEK_SET): void + { + throw new \RuntimeException("Stream is not seekable"); + } + + public function rewind(): void + { + throw new \RuntimeException("Stream is not seekable"); + } + + public function isWritable(): bool + { + return false; + } + + public function write($string): int + { + throw new \RuntimeException("Stream is not writable"); + } + + public function isReadable(): bool + { + return true; + } + + public function read($length): string + { + if ($this->eof()) { + return ''; + } + + // Read only up to maxBytesPerRead + $readLength = min($length, $this->maxBytesPerRead); + $chunk = substr($this->data, $this->position, $readLength); + $this->position += strlen($chunk); + + return $chunk; + } + + public function getContents(): string + { + if ($this->eof()) { + return ''; + } + + $contents = substr($this->data, $this->position); + $this->position = strlen($this->data); + + return $contents; + } + + public function getMetadata($key = null): mixed + { + return null; + } +} +