Skip to content

Commit 7cfcf77

Browse files
committed
Fix review regressions and streamline protocol test fixtures
1 parent e5710d3 commit 7cfcf77

17 files changed

Lines changed: 470 additions & 237 deletions

‎src/Connection/CommandArgument.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ public static function literal(array|string $string): array|string
4040
return $result;
4141
}
4242

43-
if (str_contains($string, "\r") || str_contains($string, "\n")) {
43+
if (str_contains($string, "\0")) {
44+
throw new InvalidArgumentException('IMAP strings cannot contain NUL bytes.');
45+
}
46+
47+
if (preg_match('/[^\x20-\x7E]/', $string)) {
4448
return ['{'.strlen($string).'}', $string];
4549
}
4650

‎src/Connection/Streams/FakeStream.php‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,46 @@ class FakeStream implements StreamInterface
4545
'stream_type' => 'tcp_socket/unknown',
4646
];
4747

48+
/**
49+
* The failure to report once all queued responses have been read.
50+
*/
51+
protected ?string $failureWhenEmpty = null;
52+
53+
/**
54+
* The most recently configured read timeout.
55+
*/
56+
protected ?int $timeout = null;
57+
58+
/**
59+
* Simulate a disconnection after the queued responses are consumed.
60+
*/
61+
public function disconnectWhenEmpty(): self
62+
{
63+
$this->failureWhenEmpty = 'eof';
64+
65+
return $this;
66+
}
67+
68+
/**
69+
* Simulate a timeout after the queued responses are consumed.
70+
*/
71+
public function timeoutWhenEmpty(): self
72+
{
73+
$this->failureWhenEmpty = 'timed_out';
74+
75+
return $this;
76+
}
77+
78+
/**
79+
* Apply the scripted failure when there are no responses left to read.
80+
*/
81+
protected function failWhenEmpty(): void
82+
{
83+
if (! $this->buffer && $this->failureWhenEmpty !== null) {
84+
$this->meta[$this->failureWhenEmpty] = true;
85+
}
86+
}
87+
4888
/**
4989
* Feed a line to the stream buffer with a newline character.
5090
*/
@@ -99,6 +139,8 @@ public function setMeta(string $attribute, mixed $value): self
99139
*/
100140
public function open(?string $transport = null, ?string $host = null, ?int $port = null, ?int $timeout = null, array $options = []): bool
101141
{
142+
$this->timeout = $timeout;
143+
102144
$this->connection = compact('transport', 'host', 'port', 'timeout', 'options');
103145

104146
return true;
@@ -122,8 +164,10 @@ public function read(int $length): string|false
122164
return false;
123165
}
124166

125-
if ($this->meta['eof'] && empty($this->buffer)) {
126-
return false; // EOF and no data left. Indicate end of stream.
167+
$this->failWhenEmpty();
168+
169+
if ($this->meta['timed_out'] || ($this->meta['eof'] && empty($this->buffer))) {
170+
return false;
127171
}
128172

129173
$data = implode('', $this->buffer);
@@ -156,6 +200,8 @@ public function fgets(): string|false
156200
return false;
157201
}
158202

203+
$this->failWhenEmpty();
204+
159205
// Simulate timeout/eof checks.
160206
if ($this->meta['timed_out'] || $this->meta['eof']) {
161207
return false;
@@ -199,9 +245,19 @@ public function opened(): bool
199245
*/
200246
public function setTimeout(int $seconds): bool
201247
{
248+
$this->timeout = $seconds;
249+
202250
return true;
203251
}
204252

253+
/**
254+
* Get the most recently configured read timeout.
255+
*/
256+
public function timeout(): ?int
257+
{
258+
return $this->timeout;
259+
}
260+
205261
/**
206262
* {@inheritDoc}
207263
*/

‎src/Folder.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ public function examine(): array
220220
*/
221221
public function expunge(array|int|null $uids = null): array
222222
{
223+
$this->select();
224+
223225
return $this->mailbox->connection()->expunge($uids)->map(
224226
fn (UntaggedResponse $response) => $response->tokenAt(1)->value
225227
)->all();

‎src/Idle.php‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
namespace DirectoryTree\ImapEngine;
44

5+
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
56
use DirectoryTree\ImapEngine\Idle\Events\EventInterface;
67
use DirectoryTree\ImapEngine\Idle\Events\FolderSelected;
78
use DirectoryTree\ImapEngine\Idle\Events\MessagesExist;
89
use DirectoryTree\ImapEngine\Selection\OptionInterface;
910
use DirectoryTree\ImapEngine\Selection\Result;
11+
use Generator;
1012

1113
class Idle
1214
{
@@ -61,11 +63,45 @@ public function await(callable $callback, ?callable $query = null, callable|int
6163
* Retrieve arrivals and deliver them in UID order.
6264
*/
6365
protected function deliver(callable $callback, ?callable $query): ?bool
66+
{
67+
foreach ($this->messages($query) as $message) {
68+
if ($callback($message) === false) {
69+
return false;
70+
}
71+
72+
$this->nextUid = $message->uid() + 1;
73+
}
74+
75+
return null;
76+
}
77+
78+
/**
79+
* Retrieve arrivals, retrying once if the application connection is lost.
80+
*
81+
* @return Generator<int, MessageInterface>
82+
*/
83+
protected function messages(?callable $query): Generator
84+
{
85+
try {
86+
yield from $this->fetch($query);
87+
} catch (ImapConnectionClosedException) {
88+
$this->folder->mailbox()->reconnect();
89+
90+
yield from $this->fetch($query);
91+
}
92+
}
93+
94+
/**
95+
* Fetch arrivals that belong to the watching connection's UID validity.
96+
*
97+
* @return Generator<int, MessageInterface>
98+
*/
99+
protected function fetch(?callable $query): Generator
64100
{
65101
$current = $this->folder->select(true);
66102

67103
if ($this->selection->uidValidity() !== null && $current->uidValidity() !== $this->selection->uidValidity()) {
68-
return null;
104+
return;
69105
}
70106

71107
$messages = $this->folder->messages()->with(MessageData::flags());
@@ -74,18 +110,10 @@ protected function deliver(callable $callback, ?callable $query): ?bool
74110

75111
foreach ($messages->uid($this->nextUid.':*')->orderByUid()->cursor() as $message) {
76112
// Reversed IMAP ranges can include an older UID when no arrivals exist.
77-
if ($message->uid() < $this->nextUid) {
78-
continue;
113+
if ($message->uid() >= $this->nextUid) {
114+
yield $message;
79115
}
80-
81-
if ($callback($message) === false) {
82-
return false;
83-
}
84-
85-
$this->nextUid = $message->uid() + 1;
86116
}
87-
88-
return null;
89117
}
90118

91119
/**

‎src/Testing/FakeFolderRepository.php‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ class FakeFolderRepository implements FolderRepositoryInterface
2525
public function __construct(
2626
protected MailboxInterface $mailbox,
2727
protected FolderCollection $folders = new FolderCollection
28-
) {
29-
$this->folders = clone $folders;
30-
}
28+
) {}
3129

3230
/**
3331
* {@inheritDoc}

‎tests/Support/ScriptedFolder.php‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Tests\Support;
4+
5+
use DirectoryTree\ImapEngine\Folder;
6+
use DirectoryTree\ImapEngine\Idle\Events\EventInterface;
7+
use DirectoryTree\ImapEngine\Mailbox;
8+
use DirectoryTree\ImapEngine\Selection\OptionInterface;
9+
10+
class ScriptedFolder extends Folder
11+
{
12+
/**
13+
* Create a folder with events to deliver to its watcher.
14+
*
15+
* @param array<EventInterface> $events
16+
*/
17+
public function __construct(Mailbox $mailbox, string $path, protected array $events)
18+
{
19+
parent::__construct($mailbox, $path);
20+
}
21+
22+
/**
23+
* Deliver scripted events until the consumer stops watching.
24+
*/
25+
public function events(callable $callback, callable|int $timeout = 300, OptionInterface ...$options): void
26+
{
27+
foreach ($this->events as $event) {
28+
if ($callback($event) === false) {
29+
break;
30+
}
31+
}
32+
}
33+
}

‎tests/Support/ScriptedMailbox.php‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Tests\Support;
4+
5+
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
6+
use DirectoryTree\ImapEngine\Mailbox;
7+
use Illuminate\Support\Collection;
8+
use LogicException;
9+
10+
class ScriptedMailbox extends Mailbox
11+
{
12+
/**
13+
* Sessions shared with cloned mailboxes, in connection order.
14+
*
15+
* @var Collection<int, ConnectionInterface>
16+
*/
17+
protected Collection $connections;
18+
19+
/**
20+
* Create a mailbox with the sessions it is allowed to open.
21+
*/
22+
public function __construct(ConnectionInterface ...$connections)
23+
{
24+
parent::__construct();
25+
26+
$this->connections = new Collection($connections);
27+
}
28+
29+
/**
30+
* Connect using the next scripted session.
31+
*/
32+
public function connect(?ConnectionInterface $connection = null): void
33+
{
34+
if ($this->connected()) {
35+
return;
36+
}
37+
38+
parent::connect($connection ?? $this->connections->shift()
39+
?? throw new LogicException('No scripted connections remain.'));
40+
}
41+
}

‎tests/Unit/Connection/CommandArgumentTest.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,11 @@
5353
test('list returns empty parentheses for an empty array', function () {
5454
expect(CommandArgument::list([]))->toBe('()');
5555
});
56+
57+
test('literal preserves control and non-ASCII bytes', function (string $input) {
58+
expect(CommandArgument::literal($input))->toBe(['{'.strlen($input).'}', $input]);
59+
})->with(["password\tvalue", "value\x01", "value\x7f", 'café', "value\xff"]);
60+
61+
test('literal rejects NUL bytes', function () {
62+
expect(fn () => CommandArgument::literal("pass\0word"))->toThrow(InvalidArgumentException::class);
63+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
use DirectoryTree\ImapEngine\Connection\Streams\FakeStream;
4+
5+
test('scripted stream failures preserve queued responses before failing', function (string $failure, string $metadata, bool $readBytes) {
6+
$stream = (new FakeStream)->{$failure}();
7+
$stream->open();
8+
$stream->feed(['first', 'second']);
9+
10+
if ($readBytes) {
11+
expect($stream->read(3))->toBe('fir');
12+
expect($stream->meta()[$metadata])->toBeFalse();
13+
expect($stream->read(100))->toBe("st\r\nsecond\r\n");
14+
} else {
15+
expect($stream->fgets())->toBe("first\r\n");
16+
expect($stream->meta()[$metadata])->toBeFalse();
17+
expect($stream->fgets())->toBe("second\r\n");
18+
}
19+
20+
expect($stream->meta()[$metadata])->toBeFalse();
21+
expect($readBytes ? $stream->read(1) : $stream->fgets())->toBeFalse();
22+
expect($stream->meta()[$metadata])->toBeTrue();
23+
})->with([
24+
'disconnect while reading lines' => ['disconnectWhenEmpty', 'eof', false],
25+
'disconnect while reading bytes' => ['disconnectWhenEmpty', 'eof', true],
26+
'timeout while reading lines' => ['timeoutWhenEmpty', 'timed_out', false],
27+
'timeout while reading bytes' => ['timeoutWhenEmpty', 'timed_out', true],
28+
]);
29+
30+
test('an unscripted empty stream does not report a disconnect or timeout', function () {
31+
$stream = new FakeStream;
32+
$stream->open();
33+
34+
expect($stream->fgets())->toBeFalse();
35+
expect($stream->read(1))->toBe('');
36+
expect($stream->meta())->toMatchArray(['eof' => false, 'timed_out' => false]);
37+
38+
$stream->feed('arrived later');
39+
40+
expect($stream->fgets())->toBe("arrived later\r\n");
41+
});

0 commit comments

Comments
 (0)