diff options
Diffstat (limited to 'vendor/maennchen/zipstream-php/src/File.php')
-rw-r--r-- | vendor/maennchen/zipstream-php/src/File.php | 420 |
1 files changed, 420 insertions, 0 deletions
diff --git a/vendor/maennchen/zipstream-php/src/File.php b/vendor/maennchen/zipstream-php/src/File.php new file mode 100644 index 000000000..0462196e2 --- /dev/null +++ b/vendor/maennchen/zipstream-php/src/File.php @@ -0,0 +1,420 @@ +<?php + +declare(strict_types=1); + +namespace ZipStream; + +use Closure; +use DateTimeInterface; +use DeflateContext; +use RuntimeException; +use ZipStream\Exception\FileSizeIncorrectException; +use ZipStream\Exception\OverflowException; +use ZipStream\Exception\ResourceActionException; +use ZipStream\Exception\SimulationFileUnknownException; +use ZipStream\Exception\StreamNotReadableException; +use ZipStream\Exception\StreamNotSeekableException; + +/** + * @internal + */ +class File +{ + private const CHUNKED_READ_BLOCK_SIZE = 0x1000000; + + private Version $version; + + private int $compressedSize = 0; + + private int $uncompressedSize = 0; + + private int $crc = 0; + + private int $generalPurposeBitFlag = 0; + + private readonly string $fileName; + + /** + * @var resource|null + */ + private $stream; + + /** + * @param Closure $dataCallback + * @psalm-param Closure(): resource $dataCallback + */ + public function __construct( + string $fileName, + private readonly Closure $dataCallback, + private readonly OperationMode $operationMode, + private readonly int $startOffset, + private readonly CompressionMethod $compressionMethod, + private readonly string $comment, + private readonly DateTimeInterface $lastModificationDateTime, + private readonly int $deflateLevel, + private readonly ?int $maxSize, + private readonly ?int $exactSize, + private readonly bool $enableZip64, + private readonly bool $enableZeroHeader, + private readonly Closure $send, + private readonly Closure $recordSentBytes, + ) { + $this->fileName = self::filterFilename($fileName); + $this->checkEncoding(); + + if ($this->enableZeroHeader) { + $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER; + } + + $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE; + } + + public function cloneSimulationExecution(): self + { + return new self( + $this->fileName, + $this->dataCallback, + OperationMode::NORMAL, + $this->startOffset, + $this->compressionMethod, + $this->comment, + $this->lastModificationDateTime, + $this->deflateLevel, + $this->maxSize, + $this->exactSize, + $this->enableZip64, + $this->enableZeroHeader, + $this->send, + $this->recordSentBytes, + ); + } + + public function process(): string + { + $forecastSize = $this->forecastSize(); + + if ($this->enableZeroHeader) { + // No calculation required + } elseif ($this->isSimulation() && $forecastSize !== null) { + $this->uncompressedSize = $forecastSize; + $this->compressedSize = $forecastSize; + } else { + $this->readStream(send: false); + if (rewind($this->unpackStream()) === false) { + throw new ResourceActionException('rewind', $this->unpackStream()); + } + } + + $this->addFileHeader(); + + $detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null); + + if ( + $this->isSimulation() && + $detectedSize !== null + ) { + ($this->recordSentBytes)($detectedSize); + } else { + $this->readStream(send: true); + } + + $this->addFileFooter(); + return $this->getCdrFile(); + } + + /** + * @return resource + */ + private function unpackStream() + { + if ($this->stream) { + return $this->stream; + } + + if ($this->operationMode === OperationMode::SIMULATE_STRICT) { + throw new SimulationFileUnknownException(); + } + + $this->stream = ($this->dataCallback)(); + + if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) { + throw new StreamNotSeekableException(); + } + if (!( + str_contains(stream_get_meta_data($this->stream)['mode'], 'r') + || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+') + || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+') + || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+') + || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+') + )) { + throw new StreamNotReadableException(); + } + + return $this->stream; + } + + private function forecastSize(): ?int + { + if ($this->compressionMethod !== CompressionMethod::STORE) { + return null; + } + if ($this->exactSize !== null) { + return $this->exactSize; + } + $fstat = fstat($this->unpackStream()); + if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) { + return null; + } + + if ($this->maxSize !== null && $this->maxSize < $fstat['size']) { + return $this->maxSize; + } + + return $fstat['size']; + } + + /** + * Create and send zip header for this file. + */ + private function addFileHeader(): void + { + $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64; + + $footer = $this->buildZip64ExtraBlock($forceEnableZip64); + + $zip64Enabled = $footer !== ''; + + if ($zip64Enabled) { + $this->version = Version::ZIP64; + } + + if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) { + // Put the tricky entry to + // force Linux unzip to lookup EFS flag. + $footer .= Zs\ExtendedInformationExtraField::generate(); + } + + $data = LocalFileHeader::generate( + versionNeededToExtract: $this->version->value, + generalPurposeBitFlag: $this->generalPurposeBitFlag, + compressionMethod: $this->compressionMethod, + lastModificationDateTime: $this->lastModificationDateTime, + crc32UncompressedData: $this->crc, + compressedSize: $zip64Enabled + ? 0xFFFFFFFF + : $this->compressedSize, + uncompressedSize: $zip64Enabled + ? 0xFFFFFFFF + : $this->uncompressedSize, + fileName: $this->fileName, + extraField: $footer, + ); + + + ($this->send)($data); + } + + /** + * Strip characters that are not legal in Windows filenames + * to prevent compatibility issues + */ + private static function filterFilename( + /** + * Unprocessed filename + */ + string $fileName + ): string { + // strip leading slashes from file name + // (fixes bug in windows archive viewer) + $fileName = ltrim($fileName, '/'); + + return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName); + } + + private function checkEncoding(): void + { + // Sets Bit 11: Language encoding flag (EFS). If this bit is set, + // the filename and comment fields for this file + // MUST be encoded using UTF-8. (see APPENDIX D) + if (mb_check_encoding($this->fileName, 'UTF-8') && + mb_check_encoding($this->comment, 'UTF-8')) { + $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS; + } + } + + private function buildZip64ExtraBlock(bool $force = false): string + { + $outputZip64ExtraBlock = false; + + $originalSize = null; + if ($force || $this->uncompressedSize > 0xFFFFFFFF) { + $outputZip64ExtraBlock = true; + $originalSize = $this->uncompressedSize; + } + + $compressedSize = null; + if ($force || $this->compressedSize > 0xFFFFFFFF) { + $outputZip64ExtraBlock = true; + $compressedSize = $this->compressedSize; + } + + // If this file will start over 4GB limit in ZIP file, + // CDR record will have to use Zip64 extension to describe offset + // to keep consistency we use the same value here + $relativeHeaderOffset = null; + if ($this->startOffset > 0xFFFFFFFF) { + $outputZip64ExtraBlock = true; + $relativeHeaderOffset = $this->startOffset; + } + + if (!$outputZip64ExtraBlock) { + return ''; + } + + if (!$this->enableZip64) { + throw new OverflowException(); + } + + return Zip64\ExtendedInformationExtraField::generate( + originalSize: $originalSize, + compressedSize: $compressedSize, + relativeHeaderOffset: $relativeHeaderOffset, + diskStartNumber: null, + ); + } + + private function addFileFooter(): void + { + if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) { + throw new OverflowException(); + } + + if (!$this->enableZeroHeader) { + return; + } + + if ($this->version === Version::ZIP64) { + $footer = Zip64\DataDescriptor::generate( + crc32UncompressedData: $this->crc, + compressedSize: $this->compressedSize, + uncompressedSize: $this->uncompressedSize, + ); + } else { + $footer = DataDescriptor::generate( + crc32UncompressedData: $this->crc, + compressedSize: $this->compressedSize, + uncompressedSize: $this->uncompressedSize, + ); + } + + ($this->send)($footer); + } + + private function readStream(bool $send): void + { + $this->compressedSize = 0; + $this->uncompressedSize = 0; + $hash = hash_init('crc32b'); + + $deflate = $this->compressionInit(); + + while ( + !feof($this->unpackStream()) && + ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) && + ($this->exactSize === null || $this->uncompressedSize < $this->exactSize) + ) { + $readLength = min( + ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize, + ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize, + self::CHUNKED_READ_BLOCK_SIZE + ); + + $data = fread($this->unpackStream(), $readLength); + + hash_update($hash, $data); + + $this->uncompressedSize += strlen($data); + + if ($deflate) { + $data = deflate_add( + $deflate, + $data, + feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH + ); + } + + $this->compressedSize += strlen($data); + + if ($send) { + ($this->send)($data); + } + } + + if ($this->exactSize !== null && $this->uncompressedSize !== $this->exactSize) { + throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize); + } + + $this->crc = hexdec(hash_final($hash)); + } + + private function compressionInit(): ?DeflateContext + { + switch ($this->compressionMethod) { + case CompressionMethod::STORE: + // Noting to do + return null; + case CompressionMethod::DEFLATE: + $deflateContext = deflate_init( + ZLIB_ENCODING_RAW, + ['level' => $this->deflateLevel] + ); + + if (!$deflateContext) { + // @codeCoverageIgnoreStart + throw new RuntimeException("Can't initialize deflate context."); + // @codeCoverageIgnoreEnd + } + + // False positive, resource is no longer returned from this function + return $deflateContext; + default: + // @codeCoverageIgnoreStart + throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true)); + // @codeCoverageIgnoreEnd + } + } + + private function getCdrFile(): string + { + $footer = $this->buildZip64ExtraBlock(); + + return CentralDirectoryFileHeader::generate( + versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY, + versionNeededToExtract: $this->version->value, + generalPurposeBitFlag: $this->generalPurposeBitFlag, + compressionMethod: $this->compressionMethod, + lastModificationDateTime: $this->lastModificationDateTime, + crc32: $this->crc, + compressedSize: $this->compressedSize > 0xFFFFFFFF + ? 0xFFFFFFFF + : $this->compressedSize, + uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF + ? 0xFFFFFFFF + : $this->uncompressedSize, + fileName: $this->fileName, + extraField: $footer, + fileComment: $this->comment, + diskNumberStart: 0, + internalFileAttributes: 0, + externalFileAttributes: 32, + relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF + ? 0xFFFFFFFF + : $this->startOffset, + ); + } + + private function isSimulation(): bool + { + return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT; + } +} |