diff --git a/src/Connection/ImapTokenizer.php b/src/Connection/ImapTokenizer.php index 17e83f0..2755ba0 100644 --- a/src/Connection/ImapTokenizer.php +++ b/src/Connection/ImapTokenizer.php @@ -387,6 +387,20 @@ protected function readAtom(): Atom $this->advance(); } + // If no value was read, we will throw an exception since + // an atom must contain at least one valid character. + if ($value === '') { + if ($char === null) { + throw new ImapStreamException('Unexpected end of stream while reading atom'); + } + + throw new ImapParserException(sprintf( + 'Unexpected byte 0x%02X in response at buffer offset %d', + ord($char), + $this->position + )); + } + if (strcasecmp($value, 'NIL') === 0) { return new Nil($value); } @@ -437,7 +451,9 @@ protected function ensureBuffer(int $length): void while ((strlen($this->buffer) - $this->position) < $length) { $data = $this->stream->fgets(); - if ($data === false) { + // If the stream did not return any data, we will stop + // filling the buffer until another read is attempted. + if ($data === false || $data === '') { return; } diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 1aee80f..54f251c 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -59,15 +59,21 @@ function folder(): Folder test('first or fail', function () { $folder = folder(); - expect(fn () => $folder->messages()->firstOrFail())->toThrow(ItemNotFoundException::class); - $uid = $folder->messages()->append( new DraftMessage(from: 'foo@example.com', text: 'hello world'), ); - expect($folder->messages()->firstOrFail()->uid())->toBe($uid); + $message = $folder->messages()->firstOrFail(); + + expect($message->uid())->toBe($uid); }); +test('first or fail throws exception', function () { + $folder = folder(); + + $folder->messages()->firstOrFail(); +})->throws(ItemNotFoundException::class); + test('find', function () { $folder = folder(); @@ -93,13 +99,17 @@ function folder(): Folder ), ); - expect($folder->messages()->findOrFail($uid))->toBeInstanceOf(Message::class); + $message = $folder->messages()->findOrFail($uid); - expect(function () use ($folder) { - $folder->messages()->findOrFail(999); - })->toThrow(ItemNotFoundException::class); + expect($message)->toBeInstanceOf(Message::class); }); +test('find or fail throws exception', function () { + $folder = folder(); + + $folder->messages()->findOrFail(999); +})->throws(ItemNotFoundException::class); + test('get without fetches', function () { $folder = folder(); diff --git a/tests/Unit/Connection/ImapTokenizerTest.php b/tests/Unit/Connection/ImapTokenizerTest.php index b32d42c..bd84043 100644 --- a/tests/Unit/Connection/ImapTokenizerTest.php +++ b/tests/Unit/Connection/ImapTokenizerTest.php @@ -207,6 +207,74 @@ $tokenizer->nextToken(); })->throws(ImapParserException::class); +test('tokenizer throws exception for an unexpected byte instead of returning an empty atom', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw("TAG1 OK L\x80GIN completed\r\n"); + + $tokenizer = new ImapTokenizer($stream); + + expect($tokenizer->nextToken()->value)->toBe('TAG1'); + expect($tokenizer->nextToken()->value)->toBe('OK'); + expect($tokenizer->nextToken()->value)->toBe('L'); + + $tokenizer->nextToken(); +})->throws(ImapParserException::class, 'Unexpected byte 0x80 in response at buffer offset 9'); + +test('tokenizer throws exception for an unexpected atom delimiter', function (string $delimiter, string $hex) { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw($delimiter); + + $tokenizer = new ImapTokenizer($stream); + + expect(fn () => $tokenizer->nextToken())->toThrow( + ImapParserException::class, + "Unexpected byte 0x{$hex} in response at buffer offset 0" + ); +})->with([ + 'closing curly brace' => ['}', '7D'], + 'closing angle bracket' => ['>', '3E'], +]); + +test('tokenizer stops filling the buffer when the stream returns an empty string', function () { + $stream = new class extends FakeStream + { + public int $reads = 0; + + public function fgets(): string|false + { + if ($this->reads++ > 1) { + throw new RuntimeException('The tokenizer continued reading after an empty stream read'); + } + + return ''; + } + }; + + $stream->open(); + + $tokenizer = new ImapTokenizer($stream); + + expect($tokenizer->nextToken())->toBeNull(); + expect($stream->reads)->toBe(2); +}); + +test('tokenizer does not treat zero stream data as empty', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feedRaw(['0', "\r\n"]); + + $tokenizer = new ImapTokenizer($stream); + + $token = $tokenizer->nextToken(); + + expect($token->value)->toBe('0'); +}); + test('tokenizer throws exception for unterminated quoted string', function () { $stream = new FakeStream; $stream->open();