• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

azjezz / psl / 23100454484

15 Mar 2026 01:16AM UTC coverage: 95.628% (-2.8%) from 98.421%
23100454484

Pull #629

github

azjezz
feat(encoding): introduce streaming IO handles for Base64, QuotedPrintable, and Hex

Signed-off-by: azjezz <azjezz@protonmail.com>
Pull Request #629: feat(encoding): introduce streaming IO handles for Base64, QuotedPrintable, and Hex

479 of 797 new or added lines in 13 files covered. (60.1%)

2 existing lines in 1 file now uncovered.

10455 of 10933 relevant lines covered (95.63%)

32.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.75
/src/Psl/Encoding/Base64/DecodingReadHandle.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Encoding\Base64;
6

7
use Psl;
8
use Psl\Async\CancellationTokenInterface;
9
use Psl\Async\NullCancellationToken;
10
use Psl\Encoding\Exception;
11
use Psl\IO;
12

13
use function preg_replace;
14
use function strlen;
15
use function strpos;
16
use function substr;
17

18
use const PHP_EOL;
19

20
/**
21
 * A read handle that decodes base64-encoded data from an inner readable handle.
22
 *
23
 * Reads chunks from the inner handle, strips whitespace, and decodes complete 4-byte groups.
24
 * On EOF, decodes any remaining bytes (which must be valid base64 with proper padding).
25
 */
26
final class DecodingReadHandle implements IO\BufferedReadHandleInterface
27
{
28
    use IO\ReadHandleConvenienceMethodsTrait;
29

30
    private string $buffer = '';
31
    private string $remainder = '';
32
    private bool $eof = false;
33

34
    public function __construct(
35
        private readonly IO\ReadHandleInterface $handle,
36
        private readonly Variant $variant = Variant::Standard,
37
        private readonly bool $padding = true,
38
    ) {}
17✔
39

40
    /**
41
     * {@inheritDoc}
42
     */
43
    public function reachedEndOfDataSource(): bool
44
    {
45
        return $this->eof && $this->buffer === '';
7✔
46
    }
47

48
    /**
49
     * {@inheritDoc}
50
     */
51
    public function tryRead(null|int $max_bytes = null): string
52
    {
NEW
53
        if ($this->buffer === '' && !$this->eof) {
×
NEW
54
            $this->fillBuffer();
×
55
        }
56

NEW
57
        if ($this->buffer === '') {
×
NEW
58
            return '';
×
59
        }
60

NEW
61
        if (null === $max_bytes || $max_bytes >= strlen($this->buffer)) {
×
NEW
62
            $result = $this->buffer;
×
NEW
63
            $this->buffer = '';
×
NEW
64
            return $result;
×
65
        }
66

NEW
67
        $result = substr($this->buffer, 0, $max_bytes);
×
NEW
68
        $this->buffer = substr($this->buffer, $max_bytes);
×
NEW
69
        return $result;
×
70
    }
71

72
    /**
73
     * {@inheritDoc}
74
     */
75
    public function read(
76
        null|int $max_bytes = null,
77
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
78
    ): string {
79
        if ($this->eof && $this->buffer === '') {
8✔
NEW
80
            return '';
×
81
        }
82

83
        if ($this->buffer === '') {
8✔
84
            $this->fillBuffer($cancellation);
8✔
85
        }
86

87
        if ($this->buffer === '') {
7✔
88
            return '';
7✔
89
        }
90

91
        if (null === $max_bytes || $max_bytes >= strlen($this->buffer)) {
6✔
92
            $result = $this->buffer;
6✔
93
            $this->buffer = '';
6✔
94
            return $result;
6✔
95
        }
96

97
        $result = substr($this->buffer, 0, $max_bytes);
1✔
98
        $this->buffer = substr($this->buffer, $max_bytes);
1✔
99
        return $result;
1✔
100
    }
101

102
    public function readByte(CancellationTokenInterface $cancellation = new NullCancellationToken()): string
103
    {
104
        if ($this->buffer === '' && !$this->eof) {
2✔
105
            $this->fillBuffer($cancellation);
2✔
106
        }
107

108
        if ($this->buffer === '') {
2✔
109
            throw new IO\Exception\RuntimeException('Reached EOF without any more data.');
1✔
110
        }
111

112
        $ret = $this->buffer[0];
1✔
113
        if ($ret === $this->buffer) {
1✔
114
            $this->buffer = '';
1✔
115
            return $ret;
1✔
116
        }
117

118
        $this->buffer = substr($this->buffer, 1);
1✔
119
        return $ret;
1✔
120
    }
121

122
    public function readLine(CancellationTokenInterface $cancellation = new NullCancellationToken()): null|string
123
    {
124
        $line = $this->readUntil(PHP_EOL, $cancellation);
2✔
125
        if ($line !== null) {
2✔
126
            return $line;
1✔
127
        }
128

129
        // No EOL found; return whatever remains, or null if empty
130
        if ($this->buffer === '' && !$this->eof) {
2✔
NEW
131
            $this->fillBuffer($cancellation);
×
132
        }
133

134
        if ($this->buffer === '') {
2✔
135
            return null;
2✔
136
        }
137

138
        $result = $this->buffer;
2✔
139
        $this->buffer = '';
2✔
140
        return $result;
2✔
141
    }
142

143
    public function readUntil(
144
        string $suffix,
145
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
146
    ): null|string {
147
        $suffix_len = strlen($suffix);
4✔
148
        $idx = strpos($this->buffer, $suffix);
4✔
149
        if ($idx !== false) {
4✔
150
            $result = substr($this->buffer, 0, $idx);
1✔
151
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
1✔
152
            return $result;
1✔
153
        }
154

155
        while (!$this->eof) {
4✔
156
            $offset = strlen($this->buffer) - $suffix_len + 1;
4✔
157
            $offset = $offset > 0 ? $offset : 0;
4✔
158

159
            $this->fillBuffer($cancellation);
4✔
160

161
            $idx = strpos($this->buffer, $suffix, $offset);
4✔
162
            if ($idx !== false) {
4✔
163
                $result = substr($this->buffer, 0, $idx);
2✔
164
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
2✔
165
                return $result;
2✔
166
            }
167
        }
168

169
        return null;
4✔
170
    }
171

172
    public function readUntilBounded(
173
        string $suffix,
174
        int $max_bytes,
175
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
176
    ): null|string {
177
        $suffix_len = strlen($suffix);
3✔
178
        $idx = strpos($this->buffer, $suffix);
3✔
179
        if ($idx !== false) {
3✔
NEW
180
            if ($idx > $max_bytes) {
×
NEW
181
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
182
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
183
                    $max_bytes,
×
NEW
184
                    $suffix,
×
NEW
185
                ));
×
186
            }
187

NEW
188
            $result = substr($this->buffer, 0, $idx);
×
NEW
189
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
×
NEW
190
            return $result;
×
191
        }
192

193
        if (strlen($this->buffer) > $max_bytes) {
3✔
NEW
194
            throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
195
                'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
196
                $max_bytes,
×
NEW
197
                $suffix,
×
NEW
198
            ));
×
199
        }
200

201
        while (!$this->eof) {
3✔
202
            $offset = strlen($this->buffer) - $suffix_len + 1;
3✔
203
            $offset = $offset > 0 ? $offset : 0;
3✔
204

205
            $this->fillBuffer($cancellation);
3✔
206

207
            $idx = strpos($this->buffer, $suffix, $offset);
3✔
208
            if ($idx !== false) {
3✔
209
                if ($idx > $max_bytes) {
2✔
210
                    throw new IO\Exception\OverflowException(Psl\Str\format(
1✔
211
                        'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
1✔
212
                        $max_bytes,
1✔
213
                        $suffix,
1✔
214
                    ));
1✔
215
                }
216

217
                $result = substr($this->buffer, 0, $idx);
1✔
218
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
1✔
219
                return $result;
1✔
220
            }
221

222
            if (strlen($this->buffer) > $max_bytes) {
1✔
NEW
223
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
224
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
225
                    $max_bytes,
×
NEW
226
                    $suffix,
×
NEW
227
                ));
×
228
            }
229
        }
230

231
        return null;
1✔
232
    }
233

234
    /**
235
     * @throws Exception\RangeException If the base64 data is invalid.
236
     */
237
    private function fillBuffer(CancellationTokenInterface $cancellation = new NullCancellationToken()): void
238
    {
239
        if ($this->eof) {
17✔
NEW
240
            return;
×
241
        }
242

243
        $chunk = $this->handle->read(4096, $cancellation);
17✔
244
        if ($chunk === '' && $this->handle->reachedEndOfDataSource()) {
17✔
245
            $this->eof = true;
13✔
246
            // Decode any remaining bytes.
247
            if ($this->remainder !== '') {
13✔
NEW
248
                $this->buffer .= decode($this->remainder, $this->variant, $this->padding);
×
NEW
249
                $this->remainder = '';
×
250
            }
251

252
            return;
13✔
253
        }
254

255
        // Strip whitespace.
256
        /** @var string $chunk */
257
        $chunk = preg_replace('/\s+/', '', $chunk);
15✔
258
        $data = $this->remainder . $chunk;
15✔
259

260
        // Decode complete 4-byte groups only.
261
        $length = strlen($data);
15✔
262
        $usable = $length - ($length % 4);
15✔
263

264
        if ($usable > 0) {
15✔
265
            $this->buffer .= decode(substr($data, 0, $usable), $this->variant, $this->padding);
15✔
266
            $this->remainder = substr($data, $usable);
14✔
267
        } else {
NEW
268
            $this->remainder = $data;
×
269
        }
270
    }
271
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc