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

paragonie / halite / 751

9 Aug 2019 - 0:19 coverage: 99.538% (-0.08%) from 99.615%
751

Pull #140

travis-ci

9181eb84f9c35729a3bad740fb7f9d93?size=18&default=identiconweb-flow
Don't recalculate hash if it exists and file is unmodified
Pull Request #140: Don't recalculate hash if it exists and file is unmodified

1 of 2 new or added lines in 1 file covered. (50.0%)

1294 of 1300 relevant lines covered (99.54%)

13.65 hits per line

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

97.22
/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
        } else {
111
            throw new InvalidType(
2×
112
                'Argument 1: Expected a filename or resource'
2×
113
            );
114
        }
115
        // @codeCoverageIgnoreStart
116
        $this->hashKey = !empty($key)
117
            ? $key->getRawKeyMaterial()
118
            : '';
119
        // @codeCoverageIgnoreEnd
120
        $this->hash = $this->getHash();
36×
121
    }
36×
122

123
    /**
124
     * Make sure we invoke $this->close()
125
     */
126
    public function __destruct()
127
    {
128
        $this->close();
36×
129
    }
36×
130

131
    /**
132
     * Close the file handle.
133
     * @return void
134
     */
135
    public function close(): void
136
    {
137
        if ($this->closeAfter) {
36×
138
            $this->closeAfter = false;
34×
139
            \fclose($this->fp);
34×
140
            \clearstatcache();
34×
141
        }
142
    }
36×
143

144
    /**
145
     * Calculate a BLAKE2b hash of a file
146
     *
147
     * @return string
148
     */
149
    public function getHash(): string
150
    {
151
        if ($this->hash && $this->toctouTest()) {
36×
NEW
152
            return $this->hash;
!
153
        }
154
        $init = $this->pos;
36×
155
        \fseek($this->fp, 0, SEEK_SET);
36×
156

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

180
    /**
181
     * Where are we in the buffer?
182
     * 
183
     * @return int
184
     */
185
    public function getPos(): int
186
    {
187
        return $this->pos;
24×
188
    }
189

190
    /**
191
     * How big is this buffer?
192
     * 
193
     * @return int
194
     */
195
    public function getSize(): int
196
    {
197
        return (int) $this->stat['size'];
32×
198
    }
199

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

279
    /**
280
     * Set the current cursor position to the desired location
281
     *
282
     * @param int $position
283
     * @return bool
284
     * @throws CannotPerformOperation
285
     */
286
    public function reset(int $position = 0): bool
287
    {
288
        $this->pos = $position;
26×
289
        if (\fseek($this->fp, $position, SEEK_SET) === 0) {
26×
290
            return true;
26×
291
        }
292
        // @codeCoverageIgnoreStart
293
        throw new CannotPerformOperation(
294
            'fseek() failed'
295
        );
296
        // @codeCoverageIgnoreEnd
297
    }
298

299
    /**
300
     * Run-time test to prevent TOCTOU attacks (race conditions) through
301
     * verifying that the hash matches and the current cursor position/file
302
     * size matches their values when the file was first opened.
303
     *
304
     * @throws FileModified
305
     * @return void
306
     */
307
    public function toctouTest()
308
    {
309
        if (\ftell($this->fp) !== $this->pos) {
34×
310
            // @codeCoverageIgnoreStart
311
            throw new FileModified(
312
                'Read-only file has been modified since it was opened for reading'
313
            );
314
            // @codeCoverageIgnoreEnd
315
        }
316
        $stat = \fstat($this->fp);
34×
317
        if ($stat['size'] !== $this->stat['size']) {
34×
318
            throw new FileModified(
2×
319
                'Read-only file has been modified since it was opened for reading'
2×
320
            );
321
        }
322
    }
34×
323
    
324
    /**
325
     * This is a meaningless operation for a Read-Only File!
326
     * 
327
     * @param string $buf
328
     * @param int $num (number of bytes)
329
     * @return int
330
     * @throws FileAccessDenied
331
     */
332
    public function writeBytes(string $buf, int $num = null): int
333
    {
334
        unset($buf);
2×
335
        unset($num);
2×
336
        throw new FileAccessDenied(
2×
337
            'This is a read-only file handle.'
2×
338
        );
339
    }
340
}
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