size = fstat($inStream)['size']; $this->inStream = $inStream; /** @noinspection AdditionOperationOnArraysInspection */ $options += $this->getDefaultOptions(); $this->options = $options; } /** * @return array */ protected function getDefaultOptions() { return [ ZipOptions::CHARSET => null, ]; } /** * @throws ZipException * * @return ImmutableZipContainer */ public function read() { if ($this->size < ZipConstants::END_CD_MIN_LEN) { throw new ZipException('Corrupt zip file'); } $endOfCentralDirectory = $this->readEndOfCentralDirectory(); $entries = $this->readCentralDirectory($endOfCentralDirectory); return new ImmutableZipContainer($entries, $endOfCentralDirectory->getComment()); } /** * @return array */ public function getStreamMetaData() { return stream_get_meta_data($this->inStream); } /** * Read End of central directory record. * * end of central dir signature 4 bytes (0x06054b50) * number of this disk 2 bytes * number of the disk with the * start of the central directory 2 bytes * total number of entries in the * central directory on this disk 2 bytes * total number of entries in * the central directory 2 bytes * size of the central directory 4 bytes * offset of start of central * directory with respect to * the starting disk number 4 bytes * .ZIP file comment length 2 bytes * .ZIP file comment (variable size) * * @throws ZipException * * @return EndOfCentralDirectory */ protected function readEndOfCentralDirectory() { if (!$this->findEndOfCentralDirectory()) { throw new ZipException('Invalid zip file. The end of the central directory could not be found.'); } $positionECD = ftell($this->inStream) - 4; $sizeECD = $this->size - ftell($this->inStream); $buffer = fread($this->inStream, $sizeECD); $unpack = unpack( 'vdiskNo/vcdDiskNo/vcdEntriesDisk/' . 'vcdEntries/VcdSize/VcdPos/vcommentLength', substr($buffer, 0, 18) ); if ( $unpack['diskNo'] !== 0 || $unpack['cdDiskNo'] !== 0 || $unpack['cdEntriesDisk'] !== $unpack['cdEntries'] ) { throw new ZipException( 'ZIP file spanning/splitting is not supported!' ); } // .ZIP file comment (variable sizeECD) $comment = null; if ($unpack['commentLength'] > 0) { $comment = substr($buffer, 18, $unpack['commentLength']); } // Check for ZIP64 End Of Central Directory Locator exists. $zip64ECDLocatorPosition = $positionECD - ZipConstants::ZIP64_END_CD_LOC_LEN; fseek($this->inStream, $zip64ECDLocatorPosition); // zip64 end of central dir locator // signature 4 bytes (0x07064b50) if ($zip64ECDLocatorPosition > 0 && unpack( 'V', fread($this->inStream, 4) )[1] === ZipConstants::ZIP64_END_CD_LOC) { if (!$this->isZip64Support()) { throw new ZipException('ZIP64 not supported this archive.'); } $positionECD = $this->findZip64ECDPosition(); $endCentralDirectory = $this->readZip64EndOfCentralDirectory($positionECD); $endCentralDirectory->setComment($comment); } else { $endCentralDirectory = new EndOfCentralDirectory( $unpack['cdEntries'], $unpack['cdPos'], $unpack['cdSize'], false, $comment ); } return $endCentralDirectory; } /** * @return bool */ protected function findEndOfCentralDirectory() { $max = $this->size - ZipConstants::END_CD_MIN_LEN; $min = $max >= 0xffff ? $max - 0xffff : 0; // Search for End of central directory record. for ($position = $max; $position >= $min; $position--) { fseek($this->inStream, $position); // end of central dir signature 4 bytes (0x06054b50) if (unpack('V', fread($this->inStream, 4))[1] !== ZipConstants::END_CD) { continue; } return true; } return false; } /** * Read Zip64 end of central directory locator and returns * Zip64 end of central directory position. * * number of the disk with the * start of the zip64 end of * central directory 4 bytes * relative offset of the zip64 * end of central directory record 8 bytes * total number of disks 4 bytes * * @throws ZipException * * @return int Zip64 End Of Central Directory position */ protected function findZip64ECDPosition() { $diskNo = unpack('V', fread($this->inStream, 4))[1]; $zip64ECDPos = PackUtil::unpackLongLE(fread($this->inStream, 8)); $totalDisks = unpack('V', fread($this->inStream, 4))[1]; if ($diskNo !== 0 || $totalDisks > 1) { throw new ZipException('ZIP file spanning/splitting is not supported!'); } return $zip64ECDPos; } /** * Read zip64 end of central directory locator and zip64 end * of central directory record. * * zip64 end of central dir * signature 4 bytes (0x06064b50) * size of zip64 end of central * directory record 8 bytes * version made by 2 bytes * version needed to extract 2 bytes * number of this disk 4 bytes * number of the disk with the * start of the central directory 4 bytes * total number of entries in the * central directory on this disk 8 bytes * total number of entries in the * central directory 8 bytes * size of the central directory 8 bytes * offset of start of central * directory with respect to * the starting disk number 8 bytes * zip64 extensible data sector (variable size) * * @param int $zip64ECDPosition * * @throws ZipException * * @return EndOfCentralDirectory */ protected function readZip64EndOfCentralDirectory($zip64ECDPosition) { fseek($this->inStream, $zip64ECDPosition); $buffer = fread($this->inStream, ZipConstants::ZIP64_END_OF_CD_LEN); if (unpack('V', $buffer)[1] !== ZipConstants::ZIP64_END_CD) { throw new ZipException('Expected ZIP64 End Of Central Directory Record!'); } $data = unpack( // 'Psize/vversionMadeBy/vextractVersion/' . 'VdiskNo/VcdDiskNo', substr($buffer, 16, 8) ); $cdEntriesDisk = PackUtil::unpackLongLE(substr($buffer, 24, 8)); $entryCount = PackUtil::unpackLongLE(substr($buffer, 32, 8)); $cdSize = PackUtil::unpackLongLE(substr($buffer, 40, 8)); $cdPos = PackUtil::unpackLongLE(substr($buffer, 48, 8)); // $platform = ZipPlatform::fromValue(($data['versionMadeBy'] & 0xFF00) >> 8); // $softwareVersion = $data['versionMadeBy'] & 0x00FF; if ($data['diskNo'] !== 0 || $data['cdDiskNo'] !== 0 || $entryCount !== $cdEntriesDisk) { throw new ZipException('ZIP file spanning/splitting is not supported!'); } if ($entryCount < 0 || $entryCount > 0x7fffffff) { throw new ZipException('Total Number Of Entries In The Central Directory out of range!'); } // skip zip64 extensible data sector (variable sizeEndCD) return new EndOfCentralDirectory( $entryCount, $cdPos, $cdSize, true ); } /** * Reads the central directory from the given seekable byte channel * and populates the internal tables with ZipEntry instances. * * The ZipEntry's will know all data that can be obtained from the * central directory alone, but not the data that requires the local * file header or additional data to be read. * * @param EndOfCentralDirectory $endCD * * @throws ZipException * * @return ZipEntry[] */ protected function readCentralDirectory(EndOfCentralDirectory $endCD) { $entries = []; $cdOffset = $endCD->getCdOffset(); fseek($this->inStream, $cdOffset); if (!($cdStream = fopen('php://temp', 'w+b'))) { // @codeCoverageIgnoreStart throw new ZipException('A temporary resource cannot be opened for writing.'); // @codeCoverageIgnoreEnd } stream_copy_to_stream($this->inStream, $cdStream, $endCD->getCdSize()); rewind($cdStream); for ($numEntries = $endCD->getEntryCount(); $numEntries > 0; $numEntries--) { $zipEntry = $this->readZipEntry($cdStream); $entryName = $zipEntry->getName(); /** @var UnicodePathExtraField|null $unicodePathExtraField */ $unicodePathExtraField = $zipEntry->getExtraField(UnicodePathExtraField::HEADER_ID); if ($unicodePathExtraField !== null && $unicodePathExtraField->getCrc32() === crc32($entryName)) { $unicodePath = $unicodePathExtraField->getUnicodeValue(); if ($unicodePath !== null) { $unicodePath = str_replace('\\', '/', $unicodePath); if ( $unicodePath !== '' && substr_count($entryName, '/') === substr_count($unicodePath, '/') ) { $entryName = $unicodePath; } } } $entries[$entryName] = $zipEntry; } return $entries; } /** * Read central directory entry. * * central file header signature 4 bytes (0x02014b50) * version made by 2 bytes * version needed to extract 2 bytes * general purpose bit flag 2 bytes * compression method 2 bytes * last mod file time 2 bytes * last mod file date 2 bytes * crc-32 4 bytes * compressed size 4 bytes * uncompressed size 4 bytes * file name length 2 bytes * extra field length 2 bytes * file comment length 2 bytes * disk number start 2 bytes * internal file attributes 2 bytes * external file attributes 4 bytes * relative offset of local header 4 bytes * * file name (variable size) * extra field (variable size) * file comment (variable size) * * @param resource $stream * * @throws ZipException * * @return ZipEntry */ protected function readZipEntry($stream) { if (unpack('V', fread($stream, 4))[1] !== ZipConstants::CENTRAL_FILE_HEADER) { throw new ZipException('Corrupt zip file. Cannot read zip entry.'); } $unpack = unpack( 'vversionMadeBy/vversionNeededToExtract/' . 'vgeneralPurposeBitFlag/vcompressionMethod/' . 'VlastModFile/Vcrc/VcompressedSize/' . 'VuncompressedSize/vfileNameLength/vextraFieldLength/' . 'vfileCommentLength/vdiskNumberStart/vinternalFileAttributes/' . 'VexternalFileAttributes/VoffsetLocalHeader', fread($stream, 42) ); if ($unpack['diskNumberStart'] !== 0) { throw new ZipException('ZIP file spanning/splitting is not supported!'); } $generalPurposeBitFlags = $unpack['generalPurposeBitFlag']; $isUtf8 = ($generalPurposeBitFlags & GeneralPurposeBitFlag::UTF8) !== 0; $name = fread($stream, $unpack['fileNameLength']); $createdOS = ($unpack['versionMadeBy'] & 0xFF00) >> 8; $softwareVersion = $unpack['versionMadeBy'] & 0x00FF; $extractedOS = ($unpack['versionNeededToExtract'] & 0xFF00) >> 8; $extractVersion = $unpack['versionNeededToExtract'] & 0x00FF; $dosTime = $unpack['lastModFile']; $comment = null; if ($unpack['fileCommentLength'] > 0) { $comment = fread($stream, $unpack['fileCommentLength']); } // decode code page names $fallbackCharset = null; if (!$isUtf8 && isset($this->options[ZipOptions::CHARSET])) { $charset = $this->options[ZipOptions::CHARSET]; $fallbackCharset = $charset; $name = DosCodePage::toUTF8($name, $charset); if ($comment !== null) { $comment = DosCodePage::toUTF8($comment, $charset); } } $zipEntry = ZipEntry::create( $name, $createdOS, $extractedOS, $softwareVersion, $extractVersion, $unpack['compressionMethod'], $generalPurposeBitFlags, $dosTime, $unpack['crc'], $unpack['compressedSize'], $unpack['uncompressedSize'], $unpack['internalFileAttributes'], $unpack['externalFileAttributes'], $unpack['offsetLocalHeader'], $comment, $fallbackCharset ); if ($unpack['extraFieldLength'] > 0) { $this->parseExtraFields( fread($stream, $unpack['extraFieldLength']), $zipEntry, false ); /** @var Zip64ExtraField|null $extraZip64 */ $extraZip64 = $zipEntry->getCdExtraField(Zip64ExtraField::HEADER_ID); if ($extraZip64 !== null) { $this->handleZip64Extra($extraZip64, $zipEntry); } } $this->loadLocalExtraFields($zipEntry); $this->handleExtraEncryptionFields($zipEntry); $this->handleExtraFields($zipEntry); return $zipEntry; } /** * @param string $buffer * @param ZipEntry $zipEntry * @param bool $local * * @return ExtraFieldsCollection */ protected function parseExtraFields($buffer, ZipEntry $zipEntry, $local = false) { $collection = $local ? $zipEntry->getLocalExtraFields() : $zipEntry->getCdExtraFields(); if (!empty($buffer)) { $pos = 0; $endPos = \strlen($buffer); while ($endPos - $pos >= 4) { /** @var int[] $data */ $data = unpack('vheaderId/vdataSize', substr($buffer, $pos, 4)); $pos += 4; if ($endPos - $pos - $data['dataSize'] < 0) { break; } $bufferData = substr($buffer, $pos, $data['dataSize']); $headerId = $data['headerId']; /** @var string|ZipExtraField|null $className */ $className = ZipExtraDriver::getClassNameOrNull($headerId); try { if ($className !== null) { try { $extraField = $local ? \call_user_func([$className, 'unpackLocalFileData'], $bufferData, $zipEntry) : \call_user_func([$className, 'unpackCentralDirData'], $bufferData, $zipEntry); } catch (\Throwable $e) { // skip errors while parsing invalid data continue; } } else { $extraField = new UnrecognizedExtraField($headerId, $bufferData); } $collection->add($extraField); } finally { $pos += $data['dataSize']; } } } return $collection; } /** * @param Zip64ExtraField $extraZip64 * @param ZipEntry $zipEntry */ protected function handleZip64Extra(Zip64ExtraField $extraZip64, ZipEntry $zipEntry) { $uncompressedSize = $extraZip64->getUncompressedSize(); $compressedSize = $extraZip64->getCompressedSize(); $localHeaderOffset = $extraZip64->getLocalHeaderOffset(); if ($uncompressedSize !== null) { $zipEntry->setUncompressedSize($uncompressedSize); } if ($compressedSize !== null) { $zipEntry->setCompressedSize($compressedSize); } if ($localHeaderOffset !== null) { $zipEntry->setLocalHeaderOffset($localHeaderOffset); } } /** * Read Local File Header. * * local file header signature 4 bytes (0x04034b50) * version needed to extract 2 bytes * general purpose bit flag 2 bytes * compression method 2 bytes * last mod file time 2 bytes * last mod file date 2 bytes * crc-32 4 bytes * compressed size 4 bytes * uncompressed size 4 bytes * file name length 2 bytes * extra field length 2 bytes * file name (variable size) * extra field (variable size) * * @param ZipEntry $entry * * @throws ZipException */ protected function loadLocalExtraFields(ZipEntry $entry) { $offsetLocalHeader = $entry->getLocalHeaderOffset(); fseek($this->inStream, $offsetLocalHeader); if (unpack('V', fread($this->inStream, 4))[1] !== ZipConstants::LOCAL_FILE_HEADER) { throw new ZipException(sprintf('%s (expected Local File Header)', $entry->getName())); } fseek($this->inStream, $offsetLocalHeader + ZipConstants::LFH_FILENAME_LENGTH_POS); $unpack = unpack('vfileNameLength/vextraFieldLength', fread($this->inStream, 4)); $offsetData = ftell($this->inStream) + $unpack['fileNameLength'] + $unpack['extraFieldLength']; fseek($this->inStream, $unpack['fileNameLength'], \SEEK_CUR); if ($unpack['extraFieldLength'] > 0) { $this->parseExtraFields( fread($this->inStream, $unpack['extraFieldLength']), $entry, true ); } $zipData = new ZipSourceFileData($this, $entry, $offsetData); $entry->setData($zipData); } /** * @param ZipEntry $zipEntry * * @throws ZipException */ private function handleExtraEncryptionFields(ZipEntry $zipEntry) { if ($zipEntry->isEncrypted()) { if ($zipEntry->getCompressionMethod() === ZipCompressionMethod::WINZIP_AES) { /** @var WinZipAesExtraField|null $extraField */ $extraField = $zipEntry->getExtraField(WinZipAesExtraField::HEADER_ID); if ($extraField === null) { throw new ZipException( sprintf( 'Extra field 0x%04x (WinZip-AES Encryption) expected for compression method %d', WinZipAesExtraField::HEADER_ID, $zipEntry->getCompressionMethod() ) ); } $zipEntry->setCompressionMethod($extraField->getCompressionMethod()); $zipEntry->setEncryptionMethod($extraField->getEncryptionMethod()); } else { $zipEntry->setEncryptionMethod(ZipEncryptionMethod::PKWARE); } } } /** * Handle extra data in zip records. * * This is a special method in which you can process ExtraField * and make changes to ZipEntry. * * @param ZipEntry $zipEntry */ protected function handleExtraFields(ZipEntry $zipEntry) { } /** * @param ZipSourceFileData $zipFileData * * @throws ZipException * @throws Crc32Exception * * @return resource */ public function getEntryStream(ZipSourceFileData $zipFileData) { $outStream = fopen('php://temp', 'w+b'); $this->copyUncompressedDataToStream($zipFileData, $outStream); rewind($outStream); return $outStream; } /** * @param ZipSourceFileData $zipFileData * @param resource $outStream * * @throws Crc32Exception * @throws ZipException */ public function copyUncompressedDataToStream(ZipSourceFileData $zipFileData, $outStream) { if (!\is_resource($outStream)) { throw new InvalidArgumentException('outStream is not resource'); } $entry = $zipFileData->getSourceEntry(); // if ($entry->isDirectory()) { // throw new InvalidArgumentException('Streams not supported for directories'); // } if ($entry->isStrongEncryption()) { throw new ZipException('Not support encryption zip.'); } $compressionMethod = $entry->getCompressionMethod(); fseek($this->inStream, $zipFileData->getOffset()); $filters = []; $skipCheckCrc = false; $isEncrypted = $entry->isEncrypted(); if ($isEncrypted) { if ($entry->getPassword() === null) { throw new ZipException('Can not password from entry ' . $entry->getName()); } if (ZipEncryptionMethod::isWinZipAesMethod($entry->getEncryptionMethod())) { /** @var WinZipAesExtraField|null $winZipAesExtra */ $winZipAesExtra = $entry->getExtraField(WinZipAesExtraField::HEADER_ID); if ($winZipAesExtra === null) { throw new ZipException( sprintf('WinZip AES must contain the extra field %s', WinZipAesExtraField::HEADER_ID) ); } $compressionMethod = $winZipAesExtra->getCompressionMethod(); WinZipAesDecryptionStreamFilter::register(); $cipherFilterName = WinZipAesDecryptionStreamFilter::FILTER_NAME; if ($winZipAesExtra->isV2()) { $skipCheckCrc = true; } } else { PKDecryptionStreamFilter::register(); $cipherFilterName = PKDecryptionStreamFilter::FILTER_NAME; } $encContextFilter = stream_filter_append( $this->inStream, $cipherFilterName, \STREAM_FILTER_READ, [ 'entry' => $entry, ] ); if (!$encContextFilter) { throw new \RuntimeException('Not apply filter ' . $cipherFilterName); } $filters[] = $encContextFilter; } // hack, see https://groups.google.com/forum/#!topic/alt.comp.lang.php/37_JZeW63uc $pos = ftell($this->inStream); rewind($this->inStream); fseek($this->inStream, $pos); $contextDecompress = null; switch ($compressionMethod) { case ZipCompressionMethod::STORED: // file without compression, do nothing break; case ZipCompressionMethod::DEFLATED: if (!($contextDecompress = stream_filter_append( $this->inStream, 'zlib.inflate', \STREAM_FILTER_READ ))) { throw new \RuntimeException('Could not append filter "zlib.inflate" to stream'); } $filters[] = $contextDecompress; break; case ZipCompressionMethod::BZIP2: if (!($contextDecompress = stream_filter_append( $this->inStream, 'bzip2.decompress', \STREAM_FILTER_READ ))) { throw new \RuntimeException('Could not append filter "bzip2.decompress" to stream'); } $filters[] = $contextDecompress; break; default: throw new ZipException( sprintf( '%s (compression method %d (%s) is not supported)', $entry->getName(), $compressionMethod, ZipCompressionMethod::getCompressionMethodName($compressionMethod) ) ); } $limit = $zipFileData->getUncompressedSize(); $offset = 0; $chunkSize = 8192; try { if ($skipCheckCrc) { while ($offset < $limit) { $length = min($chunkSize, $limit - $offset); $buffer = fread($this->inStream, $length); if ($buffer === false) { throw new ZipException(sprintf('Error reading the contents of entry "%s".', $entry->getName())); } fwrite($outStream, $buffer); $offset += $length; } } else { $contextHash = hash_init('crc32b'); while ($offset < $limit) { $length = min($chunkSize, $limit - $offset); $buffer = fread($this->inStream, $length); if ($buffer === false) { throw new ZipException(sprintf('Error reading the contents of entry "%s".', $entry->getName())); } fwrite($outStream, $buffer); hash_update($contextHash, $buffer); $offset += $length; } $expectedCrc = (int) hexdec(hash_final($contextHash)); if ($expectedCrc !== $entry->getCrc()) { throw new Crc32Exception($entry->getName(), $expectedCrc, $entry->getCrc()); } } } finally { for ($i = \count($filters); $i > 0; $i--) { stream_filter_remove($filters[$i - 1]); } } } /** * @param ZipSourceFileData $zipData * @param resource $outStream */ public function copyCompressedDataToStream(ZipSourceFileData $zipData, $outStream) { if ($zipData->getCompressedSize() > 0) { fseek($this->inStream, $zipData->getOffset()); stream_copy_to_stream($this->inStream, $outStream, $zipData->getCompressedSize()); } } /** * @return bool */ protected function isZip64Support() { return \PHP_INT_SIZE === 8; // true for 64bit system } public function close() { if (\is_resource($this->inStream)) { fclose($this->inStream); } } public function __destruct() { $this->close(); } }