Proyectos de Subversion Moodle

Rev

| 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
98
        } elseif ($this->isSimulation() && $forecastSize) {
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
 
110
        $detectedSize = $forecastSize ?? $this->compressedSize;
111
 
112
        if (
113
            $this->isSimulation() &&
114
            $detectedSize > 0
115
        ) {
116
            ($this->recordSentBytes)($detectedSize);
117
        } else {
118
            $this->readStream(send: true);
119
        }
120
 
121
        $this->addFileFooter();
122
        return $this->getCdrFile();
123
    }
124
 
125
    /**
126
     * @return resource
127
     */
128
    private function unpackStream()
129
    {
130
        if ($this->stream) {
131
            return $this->stream;
132
        }
133
 
134
        if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
135
            throw new SimulationFileUnknownException();
136
        }
137
 
138
        $this->stream = ($this->dataCallback)();
139
 
140
        if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
141
            throw new StreamNotSeekableException();
142
        }
143
        if (!(
144
            str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
145
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
146
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
147
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
148
            || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
149
        )) {
150
            throw new StreamNotReadableException();
151
        }
152
 
153
        return $this->stream;
154
    }
155
 
156
    private function forecastSize(): ?int
157
    {
158
        if ($this->compressionMethod !== CompressionMethod::STORE) {
159
            return null;
160
        }
161
        if ($this->exactSize) {
162
            return $this->exactSize;
163
        }
164
        $fstat = fstat($this->unpackStream());
165
        if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
166
            return null;
167
        }
168
 
169
        if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
170
            return $this->maxSize;
171
        }
172
 
173
        return $fstat['size'];
174
    }
175
 
176
    /**
177
     * Create and send zip header for this file.
178
     */
179
    private function addFileHeader(): void
180
    {
181
        $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
182
 
183
        $footer = $this->buildZip64ExtraBlock($forceEnableZip64);
184
 
185
        $zip64Enabled = $footer !== '';
186
 
187
        if($zip64Enabled) {
188
            $this->version = Version::ZIP64;
189
        }
190
 
191
        if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
192
            // Put the tricky entry to
193
            // force Linux unzip to lookup EFS flag.
194
            $footer .= Zs\ExtendedInformationExtraField::generate();
195
        }
196
 
197
        $data = LocalFileHeader::generate(
198
            versionNeededToExtract: $this->version->value,
199
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
200
            compressionMethod: $this->compressionMethod,
201
            lastModificationDateTime: $this->lastModificationDateTime,
202
            crc32UncompressedData: $this->crc,
203
            compressedSize: $zip64Enabled
204
                ? 0xFFFFFFFF
205
                : $this->compressedSize,
206
            uncompressedSize: $zip64Enabled
207
                ? 0xFFFFFFFF
208
                : $this->uncompressedSize,
209
            fileName: $this->fileName,
210
            extraField: $footer,
211
        );
212
 
213
 
214
        ($this->send)($data);
215
    }
216
 
217
    /**
218
     * Strip characters that are not legal in Windows filenames
219
     * to prevent compatibility issues
220
     */
221
    private static function filterFilename(
222
        /**
223
         * Unprocessed filename
224
         */
225
        string $fileName
226
    ): string {
227
        // strip leading slashes from file name
228
        // (fixes bug in windows archive viewer)
229
        $fileName = ltrim($fileName, '/');
230
 
231
        return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
232
    }
233
 
234
    private function checkEncoding(): void
235
    {
236
        // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
237
        // the filename and comment fields for this file
238
        // MUST be encoded using UTF-8. (see APPENDIX D)
239
        if (mb_check_encoding($this->fileName, 'UTF-8') &&
240
                mb_check_encoding($this->comment, 'UTF-8')) {
241
            $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
242
        }
243
    }
244
 
245
    private function buildZip64ExtraBlock(bool $force = false): string
246
    {
247
        $outputZip64ExtraBlock = false;
248
 
249
        $originalSize = null;
250
        if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
251
            $outputZip64ExtraBlock = true;
252
            $originalSize = $this->uncompressedSize;
253
        }
254
 
255
        $compressedSize = null;
256
        if ($force || $this->compressedSize > 0xFFFFFFFF) {
257
            $outputZip64ExtraBlock = true;
258
            $compressedSize = $this->compressedSize;
259
        }
260
 
261
        // If this file will start over 4GB limit in ZIP file,
262
        // CDR record will have to use Zip64 extension to describe offset
263
        // to keep consistency we use the same value here
264
        $relativeHeaderOffset = null;
265
        if ($this->startOffset > 0xFFFFFFFF) {
266
            $outputZip64ExtraBlock = true;
267
            $relativeHeaderOffset = $this->startOffset;
268
        }
269
 
270
        if (!$outputZip64ExtraBlock) {
271
            return '';
272
        }
273
 
274
        if (!$this->enableZip64) {
275
            throw new OverflowException();
276
        }
277
 
278
        return Zip64\ExtendedInformationExtraField::generate(
279
            originalSize: $originalSize,
280
            compressedSize: $compressedSize,
281
            relativeHeaderOffset: $relativeHeaderOffset,
282
            diskStartNumber: null,
283
        );
284
    }
285
 
286
    private function addFileFooter(): void
287
    {
288
        if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
289
            throw new OverflowException();
290
        }
291
 
292
        if (!$this->enableZeroHeader) {
293
            return;
294
        }
295
 
296
        if ($this->version === Version::ZIP64) {
297
            $footer = Zip64\DataDescriptor::generate(
298
                crc32UncompressedData: $this->crc,
299
                compressedSize: $this->compressedSize,
300
                uncompressedSize: $this->uncompressedSize,
301
            );
302
        } else {
303
            $footer = DataDescriptor::generate(
304
                crc32UncompressedData: $this->crc,
305
                compressedSize: $this->compressedSize,
306
                uncompressedSize: $this->uncompressedSize,
307
            );
308
        }
309
 
310
        ($this->send)($footer);
311
    }
312
 
313
    private function readStream(bool $send): void
314
    {
315
        $this->compressedSize = 0;
316
        $this->uncompressedSize = 0;
317
        $hash = hash_init('crc32b');
318
 
319
        $deflate = $this->compressionInit();
320
 
321
        while (
322
            !feof($this->unpackStream()) &&
323
            ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
324
            ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
325
        ) {
326
            $readLength = min(
327
                ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
328
                ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
329
                self::CHUNKED_READ_BLOCK_SIZE
330
            );
331
 
332
            $data = fread($this->unpackStream(), $readLength);
333
 
334
            hash_update($hash, $data);
335
 
336
            $this->uncompressedSize += strlen($data);
337
 
338
            if ($deflate) {
339
                $data =  deflate_add(
340
                    $deflate,
341
                    $data,
342
                    feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
343
                );
344
            }
345
 
346
            $this->compressedSize += strlen($data);
347
 
348
            if ($send) {
349
                ($this->send)($data);
350
            }
351
        }
352
 
353
        if ($this->exactSize && $this->uncompressedSize !== $this->exactSize) {
354
            throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
355
        }
356
 
357
        $this->crc = hexdec(hash_final($hash));
358
    }
359
 
360
    private function compressionInit(): ?DeflateContext
361
    {
362
        switch($this->compressionMethod) {
363
            case CompressionMethod::STORE:
364
                // Noting to do
365
                return null;
366
            case CompressionMethod::DEFLATE:
367
                $deflateContext = deflate_init(
368
                    ZLIB_ENCODING_RAW,
369
                    ['level' => $this->deflateLevel]
370
                );
371
 
372
                if (!$deflateContext) {
373
                    // @codeCoverageIgnoreStart
374
                    throw new RuntimeException("Can't initialize deflate context.");
375
                    // @codeCoverageIgnoreEnd
376
                }
377
 
378
                // False positive, resource is no longer returned from this function
379
                return $deflateContext;
380
            default:
381
                // @codeCoverageIgnoreStart
382
                throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
383
                // @codeCoverageIgnoreEnd
384
        }
385
    }
386
 
387
    private function getCdrFile(): string
388
    {
389
        $footer = $this->buildZip64ExtraBlock();
390
 
391
        return CentralDirectoryFileHeader::generate(
392
            versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
393
            versionNeededToExtract:$this->version->value,
394
            generalPurposeBitFlag: $this->generalPurposeBitFlag,
395
            compressionMethod: $this->compressionMethod,
396
            lastModificationDateTime: $this->lastModificationDateTime,
397
            crc32: $this->crc,
398
            compressedSize: $this->compressedSize > 0xFFFFFFFF
399
                ? 0xFFFFFFFF
400
                : $this->compressedSize,
401
            uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
402
                ? 0xFFFFFFFF
403
                : $this->uncompressedSize,
404
            fileName: $this->fileName,
405
            extraField: $footer,
406
            fileComment: $this->comment,
407
            diskNumberStart: 0,
408
            internalFileAttributes: 0,
409
            externalFileAttributes: 32,
410
            relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
411
                ? 0xFFFFFFFF
412
                : $this->startOffset,
413
        );
414
    }
415
 
416
    private function isSimulation(): bool
417
    {
418
        return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
419
    }
420
}