out = $out; $this->zipModel = $zipModel; } /** * @throws ZipException */ public function writeZip() { $entries = $this->zipModel->getEntries(); $outPosEntries = []; foreach ($entries as $entry) { $outPosEntries[] = new OutputOffsetEntry(ftell($this->out), $entry); $this->writeEntry($entry); } $centralDirectoryOffset = ftell($this->out); foreach ($outPosEntries as $outputEntry) { $this->writeCentralDirectoryHeader($outputEntry); } $this->writeEndOfCentralDirectoryRecord($centralDirectoryOffset); } /** * @param ZipEntry $entry * * @throws ZipException */ public function writeEntry(ZipEntry $entry) { if ($entry instanceof ZipSourceEntry) { $entry->getInputStream()->copyEntry($entry, $this); return; } $entryContent = $this->entryCommitChangesAndReturnContent($entry); $offset = ftell($this->out); $compressedSize = $entry->getCompressedSize(); $extra = $entry->getExtra(); $nameLength = \strlen($entry->getName()); $extraLength = \strlen($extra); // zip align if ( $this->zipModel->isZipAlign() && !$entry->isEncrypted() && $entry->getMethod() === ZipFile::METHOD_STORED ) { $dataAlignmentMultiple = $this->zipModel->getZipAlign(); if (StringUtil::endsWith($entry->getName(), '.so')) { $dataAlignmentMultiple = ApkAlignmentExtraField::ANDROID_COMMON_PAGE_ALIGNMENT_BYTES; } $dataMinStartOffset = $offset + ZipEntry::LOCAL_FILE_HEADER_MIN_LEN + $extraLength + $nameLength + ApkAlignmentExtraField::ALIGNMENT_ZIP_EXTRA_MIN_SIZE_BYTES; $padding = ($dataAlignmentMultiple - ($dataMinStartOffset % $dataAlignmentMultiple)) % $dataAlignmentMultiple; $alignExtra = new ApkAlignmentExtraField(); $alignExtra->setMultiple($dataAlignmentMultiple); $alignExtra->setPadding($padding); $extraFieldsCollection = clone $entry->getExtraFieldsCollection(); $extraFieldsCollection->add($alignExtra); $extra = ExtraFieldsFactory::createSerializedData($extraFieldsCollection); $extraLength = \strlen($extra); } $size = $nameLength + $extraLength; if ($size > 0xffff) { throw new ZipException( $entry->getName() . ' (the total size of ' . $size . ' bytes for the name, extra fields and comment ' . 'exceeds the maximum size of ' . 0xffff . ' bytes)' ); } $dd = $entry->isDataDescriptorRequired(); fwrite( $this->out, pack( 'VvvvVVVVvv', // local file header signature 4 bytes (0x04034b50) ZipEntry::LOCAL_FILE_HEADER_SIG, // version needed to extract 2 bytes ($entry->getExtractedOS() << 8) | $entry->getVersionNeededToExtract(), // general purpose bit flag 2 bytes $entry->getGeneralPurposeBitFlags(), // compression method 2 bytes $entry->getMethod(), // last mod file time 2 bytes // last mod file date 2 bytes $entry->getDosTime(), // crc-32 4 bytes $dd ? 0 : $entry->getCrc(), // compressed size 4 bytes $dd ? 0 : $entry->getCompressedSize(), // uncompressed size 4 bytes $dd ? 0 : $entry->getSize(), // file name length 2 bytes $nameLength, // extra field length 2 bytes $extraLength ) ); if ($nameLength > 0) { fwrite($this->out, $entry->getName()); } if ($extraLength > 0) { fwrite($this->out, $extra); } if ($entry instanceof ZipChangesEntry && !$entry->isChangedContent()) { $entry->getSourceEntry()->getInputStream()->copyEntryData($entry->getSourceEntry(), $this); } elseif ($entryContent !== null) { fwrite($this->out, $entryContent); } if ($entry->getCrc() === ZipEntry::UNKNOWN) { throw new ZipException(sprintf('No crc for entry %s', $entry->getName())); } if ($entry->getSize() === ZipEntry::UNKNOWN) { throw new ZipException(sprintf('No uncompressed size for entry %s', $entry->getName())); } if ($entry->getCompressedSize() === ZipEntry::UNKNOWN) { throw new ZipException(sprintf('No compressed size for entry %s', $entry->getName())); } if ($entry->getGeneralPurposeBitFlag(ZipEntry::GPBF_DATA_DESCRIPTOR)) { // data descriptor signature 4 bytes (0x08074b50) // crc-32 4 bytes fwrite($this->out, pack('VV', ZipEntry::DATA_DESCRIPTOR_SIG, $entry->getCrc())); // compressed size 4 or 8 bytes // uncompressed size 4 or 8 bytes if ($entry->isZip64ExtensionsRequired()) { fwrite($this->out, PackUtil::packLongLE($compressedSize)); fwrite($this->out, PackUtil::packLongLE($entry->getSize())); } else { fwrite($this->out, pack('VV', $entry->getCompressedSize(), $entry->getSize())); } } elseif ($compressedSize !== $entry->getCompressedSize()) { throw new ZipException( $entry->getName() . ' (expected compressed entry size of ' . $entry->getCompressedSize() . ' bytes, ' . 'but is actually ' . $compressedSize . ' bytes)' ); } } /** * @param ZipEntry $entry * * @throws ZipException * * @return string|null */ protected function entryCommitChangesAndReturnContent(ZipEntry $entry) { if ($entry->getCreatedOS() === ZipEntry::UNKNOWN) { $entry->setCreatedOS(ZipEntry::PLATFORM_UNIX); } if ($entry->getSoftwareVersion() === ZipEntry::UNKNOWN) { $entry->setSoftwareVersion(63); } if ($entry->getExtractedOS() === ZipEntry::UNKNOWN) { $entry->setExtractedOS(ZipEntry::PLATFORM_UNIX); } if ($entry->getTime() === ZipEntry::UNKNOWN) { $entry->setTime(time()); } $method = $entry->getMethod(); $encrypted = $entry->isEncrypted(); // See appendix D of PKWARE's ZIP File Format Specification. $utf8 = true; if ($encrypted && $entry->getPassword() === null) { throw new ZipException(sprintf('Password not set for entry %s', $entry->getName())); } // Compose General Purpose Bit Flag. $general = ($encrypted ? ZipEntry::GPBF_ENCRYPTED : 0) | ($entry->isDataDescriptorRequired() ? ZipEntry::GPBF_DATA_DESCRIPTOR : 0) | ($utf8 ? ZipEntry::GPBF_UTF8 : 0); $entryContent = null; $extraFieldsCollection = $entry->getExtraFieldsCollection(); if (!($entry instanceof ZipChangesEntry && !$entry->isChangedContent())) { $entryContent = $entry->getEntryContent(); if ($entryContent !== null) { $entry->setSize(\strlen($entryContent)); $entry->setCrc(crc32($entryContent)); if ($encrypted && $method === ZipEntry::METHOD_WINZIP_AES) { /** * @var WinZipAesEntryExtraField $field */ $field = $extraFieldsCollection->get(WinZipAesEntryExtraField::getHeaderId()); if ($field !== null) { $method = $field->getMethod(); } } switch ($method) { case ZipFile::METHOD_STORED: break; case ZipFile::METHOD_DEFLATED: $entryContent = gzdeflate($entryContent, $entry->getCompressionLevel()); break; case ZipFile::METHOD_BZIP2: $compressionLevel = $entry->getCompressionLevel() === ZipFile::LEVEL_DEFAULT_COMPRESSION ? ZipEntry::LEVEL_DEFAULT_BZIP2_COMPRESSION : $entry->getCompressionLevel(); /** @noinspection PhpComposerExtensionStubsInspection */ $entryContent = bzcompress($entryContent, $compressionLevel); if (\is_int($entryContent)) { throw new ZipException('Error bzip2 compress. Error code: ' . $entryContent); } break; case ZipEntry::UNKNOWN: $entryContent = $this->determineBestCompressionMethod($entry, $entryContent); $method = $entry->getMethod(); break; default: throw new ZipException($entry->getName() . ' (unsupported compression method ' . $method . ')'); } if ($method === ZipFile::METHOD_DEFLATED) { $bit1 = false; $bit2 = false; switch ($entry->getCompressionLevel()) { case ZipFile::LEVEL_BEST_COMPRESSION: $bit1 = true; break; case ZipFile::LEVEL_FAST: $bit2 = true; break; case ZipFile::LEVEL_SUPER_FAST: $bit1 = true; $bit2 = true; break; } $general |= ($bit1 ? ZipEntry::GPBF_COMPRESSION_FLAG1 : 0); $general |= ($bit2 ? ZipEntry::GPBF_COMPRESSION_FLAG2 : 0); } if ($encrypted) { if (\in_array( $entry->getEncryptionMethod(), [ ZipFile::ENCRYPTION_METHOD_WINZIP_AES_128, ZipFile::ENCRYPTION_METHOD_WINZIP_AES_192, ZipFile::ENCRYPTION_METHOD_WINZIP_AES_256, ], true )) { $keyStrength = WinZipAesEntryExtraField::getKeyStrangeFromEncryptionMethod( $entry->getEncryptionMethod() ); // size bits $field = ExtraFieldsFactory::createWinZipAesEntryExtra(); $field->setKeyStrength($keyStrength); $field->setMethod($method); $size = $entry->getSize(); if ($size >= 20 && $method !== ZipFile::METHOD_BZIP2) { $field->setVendorVersion(WinZipAesEntryExtraField::VV_AE_1); } else { $field->setVendorVersion(WinZipAesEntryExtraField::VV_AE_2); $entry->setCrc(0); } $extraFieldsCollection->add($field); $entry->setMethod(ZipEntry::METHOD_WINZIP_AES); $winZipAesEngine = new WinZipAesEngine($entry); $entryContent = $winZipAesEngine->encrypt($entryContent); } elseif ($entry->getEncryptionMethod() === ZipFile::ENCRYPTION_METHOD_TRADITIONAL) { $zipCryptoEngine = new TraditionalPkwareEncryptionEngine($entry); $entryContent = $zipCryptoEngine->encrypt($entryContent); } } $compressedSize = \strlen($entryContent); $entry->setCompressedSize($compressedSize); } } // Commit changes. $entry->setGeneralPurposeBitFlags($general); if ($entry->isZip64ExtensionsRequired()) { $extraFieldsCollection->add(ExtraFieldsFactory::createZip64Extra($entry)); } elseif ($extraFieldsCollection->has(Zip64ExtraField::getHeaderId())) { $extraFieldsCollection->remove(Zip64ExtraField::getHeaderId()); } return $entryContent; } /** * @param ZipEntry $entry * @param string $content * * @throws ZipException * * @return string */ protected function determineBestCompressionMethod(ZipEntry $entry, $content) { if ($content !== null) { $entryContent = gzdeflate($content, $entry->getCompressionLevel()); if (\strlen($entryContent) < \strlen($content)) { $entry->setMethod(ZipFile::METHOD_DEFLATED); return $entryContent; } $entry->setMethod(ZipFile::METHOD_STORED); } return $content; } /** * Writes a Central File Header record. * * @param OutputOffsetEntry $outEntry */ protected function writeCentralDirectoryHeader(OutputOffsetEntry $outEntry) { $entry = $outEntry->getEntry(); $compressedSize = $entry->getCompressedSize(); $size = $entry->getSize(); // This test MUST NOT include the CRC-32 because VV_AE_2 sets it to // UNKNOWN! if (($compressedSize | $size) === ZipEntry::UNKNOWN) { throw new RuntimeException('invalid entry'); } $extra = $entry->getExtra(); $extraSize = \strlen($extra); $commentLength = \strlen($entry->getComment()); fwrite( $this->out, pack( 'VvvvvVVVVvvvvvVV', // central file header signature 4 bytes (0x02014b50) self::CENTRAL_FILE_HEADER_SIG, // version made by 2 bytes ($entry->getCreatedOS() << 8) | $entry->getSoftwareVersion(), // version needed to extract 2 bytes ($entry->getExtractedOS() << 8) | $entry->getVersionNeededToExtract(), // general purpose bit flag 2 bytes $entry->getGeneralPurposeBitFlags(), // compression method 2 bytes $entry->getMethod(), // last mod file datetime 4 bytes $entry->getDosTime(), // crc-32 4 bytes $entry->getCrc(), // compressed size 4 bytes $entry->getCompressedSize(), // uncompressed size 4 bytes $entry->getSize(), // file name length 2 bytes \strlen($entry->getName()), // extra field length 2 bytes $extraSize, // file comment length 2 bytes $commentLength, // disk number start 2 bytes 0, // internal file attributes 2 bytes $entry->getInternalAttributes(), // external file attributes 4 bytes $entry->getExternalAttributes(), // relative offset of local header 4 bytes $outEntry->getOffset() ) ); // file name (variable size) fwrite($this->out, $entry->getName()); if ($extraSize > 0) { // extra field (variable size) fwrite($this->out, $extra); } if ($commentLength > 0) { // file comment (variable size) fwrite($this->out, $entry->getComment()); } } /** * @param int $centralDirectoryOffset */ protected function writeEndOfCentralDirectoryRecord($centralDirectoryOffset) { $cdEntriesCount = \count($this->zipModel); $position = ftell($this->out); $centralDirectorySize = $position - $centralDirectoryOffset; $cdEntriesZip64 = $cdEntriesCount > 0xFFFF; $cdSizeZip64 = $centralDirectorySize > 0xFFFFFFFF; $cdOffsetZip64 = $centralDirectoryOffset > 0xFFFFFFFF; $zip64Required = $cdEntriesZip64 || $cdSizeZip64 || $cdOffsetZip64; if ($zip64Required) { $zip64EndOfCentralDirectoryOffset = ftell($this->out); // find max software version, version needed to extract and most common platform list($softwareVersion, $versionNeededToExtract) = array_reduce( $this->zipModel->getEntries(), static function (array $carry, ZipEntry $entry) { $carry[0] = max($carry[0], $entry->getSoftwareVersion() & 0xFF); $carry[1] = max($carry[1], $entry->getVersionNeededToExtract() & 0xFF); return $carry; }, [10 /* simple file min ver */, 45 /* zip64 ext min ver */] ); $createdOS = $extractedOS = ZipEntry::PLATFORM_FAT; $versionMadeBy = ($createdOS << 8) | max($softwareVersion, 45 /* zip64 ext min ver */); $versionExtractedBy = ($extractedOS << 8) | max($versionNeededToExtract, 45 /* zip64 ext min ver */); // signature 4 bytes (0x06064b50) fwrite($this->out, pack('V', EndOfCentralDirectory::ZIP64_END_OF_CD_RECORD_SIG)); // size of zip64 end of central // directory record 8 bytes fwrite($this->out, PackUtil::packLongLE(44)); fwrite( $this->out, pack( 'vvVV', // version made by 2 bytes $versionMadeBy & 0xFFFF, // version needed to extract 2 bytes $versionExtractedBy & 0xFFFF, // number of this disk 4 bytes 0, // number of the disk with the // start of the central directory 4 bytes 0 ) ); // total number of entries in the // central directory on this disk 8 bytes fwrite($this->out, PackUtil::packLongLE($cdEntriesCount)); // total number of entries in the // central directory 8 bytes fwrite($this->out, PackUtil::packLongLE($cdEntriesCount)); // size of the central directory 8 bytes fwrite($this->out, PackUtil::packLongLE($centralDirectorySize)); // offset of start of central // directory with respect to // the starting disk number 8 bytes fwrite($this->out, PackUtil::packLongLE($centralDirectoryOffset)); // write zip64 end of central directory locator fwrite( $this->out, pack( 'VV', // zip64 end of central dir locator // signature 4 bytes (0x07064b50) EndOfCentralDirectory::ZIP64_END_OF_CD_LOCATOR_SIG, // number of the disk with the // start of the zip64 end of // central directory 4 bytes 0 ) ); // relative offset of the zip64 // end of central directory record 8 bytes fwrite($this->out, PackUtil::packLongLE($zip64EndOfCentralDirectoryOffset)); // total number of disks 4 bytes fwrite($this->out, pack('V', 1)); } $comment = $this->zipModel->getArchiveComment(); $commentLength = $comment !== null ? \strlen($comment) : 0; fwrite( $this->out, pack( 'VvvvvVVv', // end of central dir signature 4 bytes (0x06054b50) EndOfCentralDirectory::END_OF_CD_SIG, // number of this disk 2 bytes 0, // number of the disk with the // start of the central directory 2 bytes 0, // total number of entries in the // central directory on this disk 2 bytes $cdEntriesZip64 ? 0xFFFF : $cdEntriesCount, // total number of entries in // the central directory 2 bytes $cdEntriesZip64 ? 0xFFFF : $cdEntriesCount, // size of the central directory 4 bytes $cdSizeZip64 ? 0xFFFFFFFF : $centralDirectorySize, // offset of start of central // directory with respect to // the starting disk number 4 bytes $cdOffsetZip64 ? 0xFFFFFFFF : $centralDirectoryOffset, // .ZIP file comment length 2 bytes $commentLength ) ); if ($commentLength > 0) { // .ZIP file comment (variable size) fwrite($this->out, $comment); } } /** * @return resource */ public function getStream() { return $this->out; } }