Coveralls logob
Coveralls logo
  • Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

paragonie / halite / 744

2 Aug 2019 - 0:19 coverage: 98.931% (-0.7%) from 99.615%
744

Pull #138

travis-ci

9181eb84f9c35729a3bad740fb7f9d93?size=18&default=identiconweb-flow
Support input and remote streams
Pull Request #138: Support input and remote streams with ReadOnlyFile

3 of 12 new or added lines in 1 file covered. (25.0%)

1296 of 1310 relevant lines covered (98.93%)

13.53 hits per line

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

87.8
/src/Stream/ReadOnlyFile.php
1
<?php
2
declare(strict_types=1);
3
namespace ParagonIE\Halite\Stream;
4

5
use ParagonIE\ConstantTime\Binary;
6
use ParagonIE\Halite\Contract\StreamInterface;
7
use ParagonIE\Halite\Alerts\{
8
    CannotPerformOperation,
9
    FileAccessDenied,
10
    FileError,
11
    FileModified,
12
    InvalidType,
13
};
14
use ParagonIE\Halite\Key;
15

16
/**
17
 * Class ReadOnlyFile
18
 *
19
 * This library makes heavy use of return-type declarations,
20
 * which are a PHP 7 only feature. Read more about them here:
21
 *
22
 * @ref http://php.net/manual/en/functions.returning-values.php#functions.returning-values.type-declaration
23
 *
24
 * @package ParagonIE\Halite\Stream
25
 *
26
 * This Source Code Form is subject to the terms of the Mozilla Public
27
 * License, v. 2.0. If a copy of the MPL was not distributed with this
28
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
29
 */
30
class ReadOnlyFile implements StreamInterface
31
{
32
    const ALLOWED_MODES = ['rb'];
33
    const CHUNK = 8192; // PHP's fread() buffer is set to 8192 by default
34

35
    /**
36
     * @var bool
37
     */
38
    private $closeAfter = false;
39

40
    /**
41
     * @var resource
42
     */
43
    private $fp;
44

45
    /**
46
     * @var string
47
     */
48
    private $hash;
49

50
    /**
51
     * @var int
52
     */
53
    private $pos = 0;
54

55
    /**
56
     * @var null|string
57
     */
58
    private $hashKey = null;
59

60
    /**
61
     * @var array
62
     */
63
    private $stat = [];
64

65
    /**
66
     * ReadOnlyFile constructor.
67
     *
68
     * @param string|resource $file
69
     * @param Key|null $key
70
     *
71
     * @throws FileAccessDenied
72
     * @throws FileError
73
     * @throws InvalidType
74
     * @throws \TypeError
75
     * @psalm-suppress RedundantConditionGivenDocblockType
76
     */
77
    public function __construct($file, Key $key = null)
78
    {
79
        if (\is_string($file)) {
38×
80
            if (!\is_readable($file)) {
36×
81
                throw new FileAccessDenied(
2×
82
                    'Could not open file for reading'
2×
83
                );
84
            }
85
            /** @var resource|bool $fp */
86
            $fp = \fopen($file, 'rb');
34×
87
            // @codeCoverageIgnoreStart
88
            if (!\is_resource($fp)) {
89
                throw new FileAccessDenied(
90
                    'Could not open file for reading'
91
                );
92
            }
93
            // @codeCoverageIgnoreEnd
94
            $this->fp = $fp;
34×
95

96
            $this->closeAfter = true;
34×
97
            $this->pos = 0;
34×
98
            $this->stat = \fstat($this->fp);
34×
99
        } elseif (\is_resource($file)) {
4×
100
            /** @var array<string, string> $metadata */
101
            $metadata = \stream_get_meta_data($file);
4×
102
            if (!\in_array($metadata['mode'], (array) static::ALLOWED_MODES, true)) {
4×
103
                throw new FileAccessDenied(
2×
104
                    'Resource is in ' . $metadata['mode'] . ' mode, which is not allowed.'
2×
105
                );
106
            }
107
            $this->fp = $file;
4×
108
            $this->pos = \ftell($this->fp);
4×
109
            $this->stat = \fstat($this->fp);
4×
110
            if (!$this->stat) {
4×
111
                // The resource is remote or a stream wrapper like php://input
NEW
112
                $this->stat = [
!
113
                    'size' => 0,
114
                ];
NEW
115
                \fseek($this->fp, 0);
!
NEW
116
                while (!feof($this->fp)) {
!
NEW
117
                    $this->stat['size'] += \strlen(\fread($this->fp, 8192));
!
118
                }
119
                \fseek($this->fp, $this->pos);
4×
120
            }
121
        } else {
122
            throw new InvalidType(
2×
123
                'Argument 1: Expected a filename or resource'
2×
124
            );
125
        }
126
        // @codeCoverageIgnoreStart
127
        $this->hashKey = !empty($key)
128
            ? $key->getRawKeyMaterial()
129
            : '';
130
        // @codeCoverageIgnoreEnd
131
        $this->hash = $this->getHash();
36×
132
    }
36×
133

134
    /**
135
     * Make sure we invoke $this->close()
136
     */
137
    public function __destruct()
138
    {
139
        $this->close();
36×
140
    }
36×
141

142
    /**
143
     * Close the file handle.
144
     * @return void
145
     */
146
    public function close(): void
147
    {
148
        if ($this->closeAfter) {
36×
149
            $this->closeAfter = false;
34×
150
            \fclose($this->fp);
34×
151
            \clearstatcache();
34×
152
        }
153
    }
36×
154

155
    /**
156
     * Calculate a BLAKE2b hash of a file
157
     *
158
     * @return string
159
     */
160
    public function getHash(): string
161
    {
162
        $init = $this->pos;
36×
163
        \fseek($this->fp, 0, SEEK_SET);
36×
164

165
        // Create a hash context:
166
        $h = \sodium_crypto_generichash_init(
36×
167
            $this->hashKey,
36×
168
            \SODIUM_CRYPTO_GENERICHASH_BYTES_MAX
36×
169
        );
170
        for ($i = 0; $i < $this->stat['size']; $i += self::CHUNK) {
36×
171
            if (($i + self::CHUNK) > $this->stat['size']) {
34×
172
                $c = \fread($this->fp, ((int) $this->stat['size'] - $i));
34×
173
            } else {
174
                $c = \fread($this->fp, self::CHUNK);
26×
175
            }
176
            if (!\is_string($c)) {
34×
177
                // @codeCoverageIgnoreStart
178
                throw new FileError('Could not read file');
179
                // @codeCoverageIgnoreEnd
180
            }
181
            \sodium_crypto_generichash_update($h, $c);
34×
182
        }
183
        // Reset the file pointer's internal cursor to where it was:
184
        \fseek($this->fp, $init, SEEK_SET);
36×
185
        return \sodium_crypto_generichash_final($h);
36×
186
    }
187

188
    /**
189
     * Where are we in the buffer?
190
     * 
191
     * @return int
192
     */
193
    public function getPos(): int
194
    {
195
        return $this->pos;
24×
196
    }
197

198
    /**
199
     * How big is this buffer?
200
     * 
201
     * @return int
202
     */
203
    public function getSize(): int
204
    {
205
        return (int) $this->stat['size'];
32×
206
    }
207

208
    /**
209
     * Get information about the stream.
210
     *
211
     * @return array
212
     */
213
    public function getStreamMetadata(): array
214
    {
215
        return \stream_get_meta_data($this->fp);
!
216
    }
217
    
218
    /**
219
     * Read from a stream; prevent partial reads (also uses run-time testing to
220
     * prevent partial reads -- you can turn this off if you need performance
221
     * and aren't concerned about race condition attacks, but this isn't a
222
     * decision to make lightly!)
223
     * 
224
     * @param int $num
225
     * @param bool $skipTests Only set this to TRUE if you're absolutely sure
226
     *                           that you don't want to defend against TOCTOU /
227
     *                           race condition attacks on the filesystem!
228
     * @return string
229
     * @throws CannotPerformOperation
230
     * @throws FileAccessDenied
231
     * @throws FileModified
232
     */
233
    public function readBytes(int $num, bool $skipTests = false): string
234
    {
235
        // @codeCoverageIgnoreStart
236
        if ($num < 0) {
237
            throw new CannotPerformOperation('num < 0');
238
        } elseif ($num === 0) {
239
            return '';
240
        }
241
        if (($this->pos + $num) > $this->stat['size']) {
242
            throw new CannotPerformOperation('Out-of-bounds read');
243
        }
244
        $buf = '';
245
        // @codeCoverageIgnoreEnd
246
        $remaining = $num;
30×
247
        if (!$skipTests) {
30×
248
            $this->toctouTest();
30×
249
        }
250
        do {
251
            // @codeCoverageIgnoreStart
252
            if ($remaining <= 0) {
253
                break;
254
            }
255
            // @codeCoverageIgnoreEnd
256
            /** @var string|bool $read */
257
            $read = \fread($this->fp, $remaining);
30×
258
            if (!\is_string($read)) {
30×
259
                // @codeCoverageIgnoreStart
260
                throw new FileAccessDenied(
261
                    'Could not read from the file'
262
                );
263
                // @codeCoverageIgnoreEnd
264
            }
265
            $buf .= $read;
30×
266
            $readSize = Binary::safeStrlen($read);
30×
267
            $this->pos += $readSize;
30×
268
            $remaining -= $readSize;
30×
269
        } while ($remaining > 0);
30×
270
        return $buf;
30×
271
    }
272
    
273
    /**
274
     * Get number of bytes remaining
275
     * 
276
     * @return int
277
     */
278
    public function remainingBytes(): int
279
    {
280
        return (int) (
281
            PHP_INT_MAX & (
26×
282
                (int) $this->stat['size'] - $this->pos
26×
283
            )
284
        );
285
    }
286

287
    /**
288
     * Set the current cursor position to the desired location
289
     *
290
     * @param int $position
291
     * @return bool
292
     * @throws CannotPerformOperation
293
     */
294
    public function reset(int $position = 0): bool
295
    {
296
        $this->pos = $position;
26×
297
        if (\fseek($this->fp, $position, SEEK_SET) === 0) {
26×
298
            return true;
26×
299
        }
300
        // @codeCoverageIgnoreStart
301
        throw new CannotPerformOperation(
302
            'fseek() failed'
303
        );
304
        // @codeCoverageIgnoreEnd
305
    }
306

307
    /**
308
     * Run-time test to prevent TOCTOU attacks (race conditions) through
309
     * verifying that the hash matches and the current cursor position/file
310
     * size matches their values when the file was first opened.
311
     *
312
     * @throws FileModified
313
     * @return void
314
     */
315
    public function toctouTest()
316
    {
317
        if (\ftell($this->fp) !== $this->pos) {
30×
318
            // @codeCoverageIgnoreStart
319
            throw new FileModified(
320
                'Read-only file has been modified since it was opened for reading'
321
            );
322
            // @codeCoverageIgnoreEnd
323
        }
324
        $stat = \fstat($this->fp);
30×
325
        if (!$stat) {
30×
326
            // The resource is remote or a stream wrapper like php://input
327
            $stat = [
NEW
328
                'size' => 0,
!
329
            ];
NEW
330
            \fseek($this->fp, 0);
!
NEW
331
            while (!feof($this->fp)) {
!
NEW
332
                $stat['size'] += \strlen(\fread($this->fp, 8192));
!
333
            }
NEW
334
            \fseek($this->fp, $this->pos);
!
335
        }
336
        if ($stat['size'] !== $this->stat['size']) {
30×
337
            throw new FileModified(
2×
338
                'Read-only file has been modified since it was opened for reading'
2×
339
            );
340
        }
341
    }
30×
342
    
343
    /**
344
     * This is a meaningless operation for a Read-Only File!
345
     * 
346
     * @param string $buf
347
     * @param int $num (number of bytes)
348
     * @return int
349
     * @throws FileAccessDenied
350
     */
351
    public function writeBytes(string $buf, int $num = null): int
352
    {
353
        unset($buf);
2×
354
        unset($num);
2×
355
        throw new FileAccessDenied(
2×
356
            'This is a read-only file handle.'
2×
357
        );
358
    }
359
}
Troubleshooting · Open an Issue · Sales · Support · ENTERPRISE · CAREERS · STATUS
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2023 Coveralls, Inc