Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
 
3
declare(strict_types=1);
4
 
5
namespace ZipStream;
6
 
7
use Closure;
8
use DateTimeInterface;
9
use DeflateContext;
10
use RuntimeException;
11
use ZipStream\Exception\FileSizeIncorrectException;
12
use ZipStream\Exception\OverflowException;
13
use ZipStream\Exception\ResourceActionException;
14
use ZipStream\Exception\SimulationFileUnknownException;
15
use ZipStream\Exception\StreamNotReadableException;
16
use ZipStream\Exception\StreamNotSeekableException;
17
 
18
/**
19
 * @internal
20
 */
21
class File
22
{
23
    private const CHUNKED_READ_BLOCK_SIZE = 0x1000000;
24
 
25
    private Version $version;
26
 
27
    private int $compressedSize = 0;
28
 
29
    private int $uncompressedSize = 0;
30
 
31
    private int $crc = 0;
32
 
33
    private int $generalPurposeBitFlag = 0;
34
 
35
    private readonly string $fileName;
36
 
37
    /**
38
     * @var resource|null
39
     */
40
    private $stream;
41
 
42
    /**
43
     * @param Closure $dataCallback
44
     * @psalm-param Closure(): resource $dataCallback
45
     */
46
    public function __construct(
47
        string $fileName,
48
        private readonly Closure $dataCallback,
49
        private readonly OperationMode $operationMode,
50
        private readonly int $startOffset,
51
        private readonly CompressionMethod $compressionMethod,
52
        private readonly string $comment,
53
        private readonly DateTimeInterface $lastModificationDateTime,
54
        private readonly int $deflateLevel,
55
        private readonly ?int $maxSize,
56
        private readonly ?int $exactSize,
57
        private readonly bool $enableZip64,
58
        private readonly bool $enableZeroHeader,
59
        private readonly Closure $send,
60
        private readonly Closure $recordSentBytes,
61
    ) {
62
        $this->fileName = self::filterFilename($fileName);
63
        $this->checkEncoding();
64
 
65
        if ($this->enableZeroHeader) {
66
            $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
67
        }
68
 
69
        $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
70
    }
71
 
72
    public function cloneSimulationExecution(): self
73
    {
74
        return new self(
75
            $this->fileName,
76
            $this->dataCallback,
77
            OperationMode::NORMAL,
78
            $this->startOffset,
79
            $this->compressionMethod,
80
            $this->comment,
81
            $this->lastModificationDateTime,
82
            $this->deflateLevel,
83
            $this->maxSize,
84
            $this->exactSize,
85
            $this->enableZip64,
86
            $this->enableZeroHeader,
87
            $this->send,
88
            $this->recordSentBytes,
89
        );
90
    }
91
 
92
    public function process(): string
93
    {
94
        $forecastSize = $this->forecastSize();
95
 
96
        if ($this->enableZeroHeader) {
97
            // No calculation required
1441 ariadna 98
        } elseif ($this->isSimulation() && $forecastSize !== null) {
1 efrain 99
            $this->uncompressedSize = $forecastSize;
100
            $this->compressedSize = $forecastSize;
101
        } else {
102
            $this->readStream(send: false);
103
            if (rewind($this->unpackStream()) === false) {
104
                throw new ResourceActionException('rewind', $this->unpackStream());
105
            }
106
        }
107
 
108
        $this->addFileHeader();
109
 
1441 ariadna 110
        $detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
1 efrain 111
 
112
        if (
113
            $this->isSimulation() &&
1441 ariadna 114
            $detectedSize !== null
1 efrain 115
        ) {
1441 ariadna 116
            $this->uncompressedSize = $detectedSize;
117
            $this->compressedSize = $detectedSize;
1 efrain 118
            ($this->recordSentBytes)($detectedSize);
119
        } else {
120
            $this->readStream(send: true);
121
        }
122
 
123
        $this->addFileFooter();
124
        return $this->getCdrFile();
125
    }
126
 
127
    /**
128
     * @return resource
129
     */
130
    private function unpackStream()
131
    {
132
        if ($this->stream) {
133
            return $this->stream;
134
        }
135
 
136
        if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
137
            throw new SimulationFileUnknownException();
138
        }
139
 
140
        $this->stream = ($this->dataCallback)();
141
 
142
        if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
143
            throw new StreamNotSeekableException();
144
        }
145
        if (!(
146
            str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
147
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
148
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
149
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
150
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
151
        )) {
152
            throw new StreamNotReadableException();
153
        }
154
 
155
        return $this->stream;
156
    }
157
 
158
    private function forecastSize(): ?int
159
    {
160
        if ($this->compressionMethod !== CompressionMethod::STORE) {
161
            return null;
162
        }
1441 ariadna 163
        if ($this->exactSize !== null) {
1 efrain 164
            return $this->exactSize;
165
        }
166
        $fstat = fstat($this->unpackStream());
167
        if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
168
            return null;
169
        }
170
 
171
        if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
172
            return $this->maxSize;
173
        }
174
 
175
        return $fstat['size'];
176
    }
177
 
178
    /**
179
     * Create and send zip header for this file.
180
     */
181
    private function addFileHeader(): void
182
    {
183
        $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
184
 
185
        $footer = $this->buildZip64ExtraBlock($forceEnableZip64);
186
 
187
        $zip64Enabled = $footer !== '';
188
 
1441 ariadna 189
        if ($zip64Enabled) {
1 efrain 190
            $this->version = Version::ZIP64;
191
        }
192
 
193
        if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
194
            // Put the tricky entry to
195
            // force Linux unzip to lookup EFS flag.
196
            $footer .= Zs\ExtendedInformationExtraField::generate();
197
        }
198
 
199
        $data = LocalFileHeader::generate(
200
            versionNeededToExtract: $this->version->value,
201
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
202
            compressionMethod: $this->compressionMethod,
203
            lastModificationDateTime: $this->lastModificationDateTime,
204
            crc32UncompressedData: $this->crc,
205
            compressedSize: $zip64Enabled
206
                ? 0xFFFFFFFF
207
                : $this->compressedSize,
208
            uncompressedSize: $zip64Enabled
209
                ? 0xFFFFFFFF
210
                : $this->uncompressedSize,
211
            fileName: $this->fileName,
212
            extraField: $footer,
213
        );
214
 
215
 
216
        ($this->send)($data);
217
    }
218
 
219
    /**
220
     * Strip characters that are not legal in Windows filenames
221
     * to prevent compatibility issues
222
     */
223
    private static function filterFilename(
224
        /**
225
         * Unprocessed filename
226
         */
227
        string $fileName
228
    ): string {
229
        // strip leading slashes from file name
230
        // (fixes bug in windows archive viewer)
231
        $fileName = ltrim($fileName, '/');
232
 
233
        return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
234
    }
235
 
236
    private function checkEncoding(): void
237
    {
238
        // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
239
        // the filename and comment fields for this file
240
        // MUST be encoded using UTF-8. (see APPENDIX D)
241
        if (mb_check_encoding($this->fileName, 'UTF-8') &&
242
                mb_check_encoding($this->comment, 'UTF-8')) {
243
            $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
244
        }
245
    }
246
 
247
    private function buildZip64ExtraBlock(bool $force = false): string
248
    {
249
        $outputZip64ExtraBlock = false;
250
 
251
        $originalSize = null;
252
        if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
253
            $outputZip64ExtraBlock = true;
254
            $originalSize = $this->uncompressedSize;
255
        }
256
 
257
        $compressedSize = null;
258
        if ($force || $this->compressedSize > 0xFFFFFFFF) {
259
            $outputZip64ExtraBlock = true;
260
            $compressedSize = $this->compressedSize;
261
        }
262
 
263
        // If this file will start over 4GB limit in ZIP file,
264
        // CDR record will have to use Zip64 extension to describe offset
265
        // to keep consistency we use the same value here
266
        $relativeHeaderOffset = null;
267
        if ($this->startOffset > 0xFFFFFFFF) {
268
            $outputZip64ExtraBlock = true;
269
            $relativeHeaderOffset = $this->startOffset;
270
        }
271
 
272
        if (!$outputZip64ExtraBlock) {
273
            return '';
274
        }
275
 
276
        if (!$this->enableZip64) {
277
            throw new OverflowException();
278
        }
279
 
280
        return Zip64\ExtendedInformationExtraField::generate(
281
            originalSize: $originalSize,
282
            compressedSize: $compressedSize,
283
            relativeHeaderOffset: $relativeHeaderOffset,
284
            diskStartNumber: null,
285
        );
286
    }
287
 
288
    private function addFileFooter(): void
289
    {
290
        if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
291
            throw new OverflowException();
292
        }
293
 
294
        if (!$this->enableZeroHeader) {
295
            return;
296
        }
297
 
298
        if ($this->version === Version::ZIP64) {
299
            $footer = Zip64\DataDescriptor::generate(
300
                crc32UncompressedData: $this->crc,
301
                compressedSize: $this->compressedSize,
302
                uncompressedSize: $this->uncompressedSize,
303
            );
304
        } else {
305
            $footer = DataDescriptor::generate(
306
                crc32UncompressedData: $this->crc,
307
                compressedSize: $this->compressedSize,
308
                uncompressedSize: $this->uncompressedSize,
309
            );
310
        }
311
 
312
        ($this->send)($footer);
313
    }
314
 
315
    private function readStream(bool $send): void
316
    {
317
        $this->compressedSize = 0;
318
        $this->uncompressedSize = 0;
319
        $hash = hash_init('crc32b');
320
 
321
        $deflate = $this->compressionInit();
322
 
323
        while (
324
            !feof($this->unpackStream()) &&
325
            ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
326
            ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
327
        ) {
328
            $readLength = min(
329
                ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
330
                ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
331
                self::CHUNKED_READ_BLOCK_SIZE
332
            );
333
 
334
            $data = fread($this->unpackStream(), $readLength);
335
 
1441 ariadna 336
            if ($data === false) {
337
                throw new ResourceActionException('fread', $this->unpackStream());
338
            }
339
 
1 efrain 340
            hash_update($hash, $data);
341
 
342
            $this->uncompressedSize += strlen($data);
343
 
344
            if ($deflate) {
345
                $data =  deflate_add(
346
                    $deflate,
347
                    $data,
348
                    feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
349
                );
1441 ariadna 350
 
351
                if ($data === false) {
352
                    throw new RuntimeException('deflate_add failed');
353
                }
1 efrain 354
            }
355
 
356
            $this->compressedSize += strlen($data);
357
 
358
            if ($send) {
359
                ($this->send)($data);
360
            }
361
        }
362
 
1441 ariadna 363
        if ($this->exactSize !== null && $this->uncompressedSize !== $this->exactSize) {
1 efrain 364
            throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
365
        }
366
 
367
        $this->crc = hexdec(hash_final($hash));
368
    }
369
 
370
    private function compressionInit(): ?DeflateContext
371
    {
1441 ariadna 372
        switch ($this->compressionMethod) {
1 efrain 373
            case CompressionMethod::STORE:
374
                // Noting to do
375
                return null;
376
            case CompressionMethod::DEFLATE:
377
                $deflateContext = deflate_init(
378
                    ZLIB_ENCODING_RAW,
379
                    ['level' => $this->deflateLevel]
380
                );
381
 
382
                if (!$deflateContext) {
383
                    // @codeCoverageIgnoreStart
384
                    throw new RuntimeException("Can't initialize deflate context.");
385
                    // @codeCoverageIgnoreEnd
386
                }
387
 
388
                // False positive, resource is no longer returned from this function
389
                return $deflateContext;
390
            default:
391
                // @codeCoverageIgnoreStart
392
                throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
393
                // @codeCoverageIgnoreEnd
394
        }
395
    }
396
 
397
    private function getCdrFile(): string
398
    {
399
        $footer = $this->buildZip64ExtraBlock();
400
 
401
        return CentralDirectoryFileHeader::generate(
402
            versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
1441 ariadna 403
            versionNeededToExtract: $this->version->value,
1 efrain 404
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
405
            compressionMethod: $this->compressionMethod,
406
            lastModificationDateTime: $this->lastModificationDateTime,
407
            crc32: $this->crc,
408
            compressedSize: $this->compressedSize > 0xFFFFFFFF
409
                ? 0xFFFFFFFF
410
                : $this->compressedSize,
411
            uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
412
                ? 0xFFFFFFFF
413
                : $this->uncompressedSize,
414
            fileName: $this->fileName,
415
            extraField: $footer,
416
            fileComment: $this->comment,
417
            diskNumberStart: 0,
418
            internalFileAttributes: 0,
419
            externalFileAttributes: 32,
420
            relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
421
                ? 0xFFFFFFFF
422
                : $this->startOffset,
423
        );
424
    }
425
 
426
    private function isSimulation(): bool
427
    {
428
        return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
429
    }
430
}