diff --git a/lib/zipstream/src/Bigint.php b/lib/zipstream/src/Bigint.php deleted file mode 100644 index f2565e98014..00000000000 --- a/lib/zipstream/src/Bigint.php +++ /dev/null @@ -1,174 +0,0 @@ -fillBytes($value, 0, 8); - } - - /** - * Get an instance - * - * @param int $value - * @return Bigint - */ - public static function init(int $value = 0): self - { - return new self($value); - } - - /** - * Fill bytes from low to high - * - * @param int $low - * @param int $high - * @return Bigint - */ - public static function fromLowHigh(int $low, int $high): self - { - $bigint = new self(); - $bigint->fillBytes($low, 0, 4); - $bigint->fillBytes($high, 4, 4); - return $bigint; - } - - /** - * Get high 32 - * - * @return int - */ - public function getHigh32(): int - { - return $this->getValue(4, 4); - } - - /** - * Get value from bytes array - * - * @param int $end - * @param int $length - * @return int - */ - public function getValue(int $end = 0, int $length = 8): int - { - $result = 0; - for ($i = $end + $length - 1; $i >= $end; $i--) { - $result <<= 8; - $result |= $this->bytes[$i]; - } - return $result; - } - - /** - * Get low FF - * - * @param bool $force - * @return float - */ - public function getLowFF(bool $force = false): float - { - if ($force || $this->isOver32()) { - return (float)0xFFFFFFFF; - } - return (float)$this->getLow32(); - } - - /** - * Check if is over 32 - * - * @psalm-suppress ArgumentTypeCoercion - * @param bool $force - * @return bool - */ - public function isOver32(bool $force = false): bool - { - // value 0xFFFFFFFF already needs a Zip64 header - return $force || - max(array_slice($this->bytes, 4, 4)) > 0 || - min(array_slice($this->bytes, 0, 4)) === 0xFF; - } - - /** - * Get low 32 - * - * @return int - */ - public function getLow32(): int - { - return $this->getValue(0, 4); - } - - /** - * Get hexadecimal - * - * @return string - */ - public function getHex64(): string - { - $result = '0x'; - for ($i = 7; $i >= 0; $i--) { - $result .= sprintf('%02X', $this->bytes[$i]); - } - return $result; - } - - /** - * Add - * - * @param Bigint $other - * @return Bigint - */ - public function add(self $other): self - { - $result = clone $this; - $overflow = false; - for ($i = 0; $i < 8; $i++) { - $result->bytes[$i] += $other->bytes[$i]; - if ($overflow) { - $result->bytes[$i]++; - $overflow = false; - } - if ($result->bytes[$i] & 0x100) { - $overflow = true; - $result->bytes[$i] &= 0xFF; - } - } - if ($overflow) { - throw new OverflowException(); - } - return $result; - } - - /** - * Fill the bytes field with int - * - * @param int $value - * @param int $start - * @param int $count - * @return void - */ - protected function fillBytes(int $value, int $start, int $count): void - { - for ($i = 0; $i < $count; $i++) { - $this->bytes[$start + $i] = $i >= PHP_INT_SIZE ? 0 : $value & 0xFF; - $value >>= 8; - } - } -} diff --git a/lib/zipstream/src/CentralDirectoryFileHeader.php b/lib/zipstream/src/CentralDirectoryFileHeader.php new file mode 100644 index 00000000000..ffcfc6e9701 --- /dev/null +++ b/lib/zipstream/src/CentralDirectoryFileHeader.php @@ -0,0 +1,52 @@ +value), + new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)), + new PackField(format: 'V', value: $crc32), + new PackField(format: 'V', value: $compressedSize), + new PackField(format: 'V', value: $uncompressedSize), + new PackField(format: 'v', value: strlen($fileName)), + new PackField(format: 'v', value: strlen($extraField)), + new PackField(format: 'v', value: strlen($fileComment)), + new PackField(format: 'v', value: $diskNumberStart), + new PackField(format: 'v', value: $internalFileAttributes), + new PackField(format: 'V', value: $externalFileAttributes), + new PackField(format: 'V', value: $relativeOffsetOfLocalHeader), + ) . $fileName . $extraField . $fileComment; + } +} diff --git a/lib/zipstream/src/CompressionMethod.php b/lib/zipstream/src/CompressionMethod.php new file mode 100644 index 00000000000..51e43637025 --- /dev/null +++ b/lib/zipstream/src/CompressionMethod.php @@ -0,0 +1,106 @@ +format(DateTimeInterface::ATOM) . " can't be represented as DOS time / date."); + } +} diff --git a/lib/zipstream/src/Exception/EncodingException.php b/lib/zipstream/src/Exception/EncodingException.php deleted file mode 100644 index 5b0267d2f72..00000000000 --- a/lib/zipstream/src/Exception/EncodingException.php +++ /dev/null @@ -1,14 +0,0 @@ -resource = $resource; + parent::__construct('Function ' . $function . 'failed on resource.'); + } +} diff --git a/lib/zipstream/src/Exception/SimulationFileUnknownException.php b/lib/zipstream/src/Exception/SimulationFileUnknownException.php new file mode 100644 index 00000000000..717c1aafef3 --- /dev/null +++ b/lib/zipstream/src/Exception/SimulationFileUnknownException.php @@ -0,0 +1,19 @@ +fileName = self::filterFilename($fileName); + $this->checkEncoding(); - /** - * @var Bigint - */ - public $len; + if ($this->enableZeroHeader) { + $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER; + } - /** - * @var Bigint - */ - public $zlen; - - /** @var int */ - public $crc; - - /** - * @var Bigint - */ - public $hlen; - - /** - * @var Bigint - */ - public $ofs; - - /** - * @var int - */ - public $bits; - - /** - * @var Version - */ - public $version; - - /** - * @var ZipStream - */ - public $zip; - - /** - * @var resource - */ - private $deflate; - - /** - * @var HashContext - */ - private $hash; - - /** - * @var Method - */ - private $method; - - /** - * @var Bigint - */ - private $totalLength; - - public function __construct(ZipStream $zip, string $name, ?FileOptions $opt = null) - { - $this->zip = $zip; - - $this->name = $name; - $this->opt = $opt ?: new FileOptions(); - $this->method = $this->opt->getMethod(); - $this->version = Version::STORE(); - $this->ofs = new Bigint(); + $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE; } - public function processPath(string $path): void + public function cloneSimulationExecution(): self { - if (!is_readable($path)) { - if (!file_exists($path)) { - throw new FileNotFoundException($path); - } - throw new FileNotReadableException($path); - } - if ($this->zip->isLargeFile($path) === false) { - $data = file_get_contents($path); - $this->processData($data); + 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) { + $this->uncompressedSize = $forecastSize; + $this->compressedSize = $forecastSize; } else { - $this->method = $this->zip->opt->getLargeFileMethod(); - - $stream = new Stream(fopen($path, 'rb')); - $this->processStream($stream); - $stream->close(); + $this->readStream(send: false); + if (rewind($this->unpackStream()) === false) { + throw new ResourceActionException('rewind', $this->unpackStream()); + } } + + $this->addFileHeader(); + + $detectedSize = $forecastSize ?? $this->compressedSize; + + if ( + $this->isSimulation() && + $detectedSize > 0 + ) { + ($this->recordSentBytes)($detectedSize); + } else { + $this->readStream(send: true); + } + + $this->addFileFooter(); + return $this->getCdrFile(); } - public function processData(string $data): void + /** + * @return resource + */ + private function unpackStream() { - $this->len = new Bigint(strlen($data)); - $this->crc = crc32($data); - - // compress data if needed - if ($this->method->equals(Method::DEFLATE())) { - $data = gzdeflate($data); + if ($this->stream) { + return $this->stream; } - $this->zlen = new Bigint(strlen($data)); - $this->addFileHeader(); - $this->zip->send($data); - $this->addFileFooter(); + 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) { + 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. - * - * @return void - * @throws \ZipStream\Exception\EncodingException */ - public function addFileHeader(): void + private function addFileHeader(): void { - $name = static::filterFilename($this->name); + $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64; - // calculate name length - $nameLength = strlen($name); + $footer = $this->buildZip64ExtraBlock($forceEnableZip64); - // create dos timestamp - $time = static::dosTime($this->opt->getTime()->getTimestamp()); + $zip64Enabled = $footer !== ''; - $comment = $this->opt->getComment(); - - if (!mb_check_encoding($name, 'ASCII') || - !mb_check_encoding($comment, 'ASCII')) { - // 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($name, 'UTF-8') && - mb_check_encoding($comment, 'UTF-8')) { - $this->bits |= self::BIT_EFS_UTF8; - } + if($zip64Enabled) { + $this->version = Version::ZIP64; } - if ($this->method->equals(Method::DEFLATE())) { - $this->version = Version::DEFLATE(); + if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) { + // Put the tricky entry to + // force Linux unzip to lookup EFS flag. + $footer .= Zs\ExtendedInformationExtraField::generate(); } - $force = (bool)($this->bits & self::BIT_ZERO_HEADER) && - $this->zip->opt->isEnableZip64(); + $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, + ); - $footer = $this->buildZip64ExtraBlock($force); - // 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 - if ($this->zip->ofs->isOver32()) { - $this->version = Version::ZIP64(); - } - - $fields = [ - ['V', ZipStream::FILE_HEADER_SIGNATURE], - ['v', $this->version->getValue()], // Version needed to Extract - ['v', $this->bits], // General purpose bit flags - data descriptor flag set - ['v', $this->method->getValue()], // Compression method - ['V', $time], // Timestamp (DOS Format) - ['V', $this->crc], // CRC32 of data (0 -> moved to data descriptor footer) - ['V', $this->zlen->getLowFF($force)], // Length of compressed data (forced to 0xFFFFFFFF for zero header) - ['V', $this->len->getLowFF($force)], // Length of original data (forced to 0xFFFFFFFF for zero header) - ['v', $nameLength], // Length of filename - ['v', strlen($footer)], // Extra data (see above) - ]; - - // pack fields and calculate "total" length - $header = ZipStream::packFields($fields); - - // print header and filename - $data = $header . $name . $footer; - $this->zip->send($data); - - // save header length - $this->hlen = Bigint::init(strlen($data)); + ($this->send)($data); } /** * Strip characters that are not legal in Windows filenames * to prevent compatibility issues - * - * @param string $filename Unprocessed filename - * @return string */ - public static function filterFilename(string $filename): string - { + private static function filterFilename( + /** + * Unprocessed filename + */ + string $fileName + ): string { // strip leading slashes from file name // (fixes bug in windows archive viewer) - $filename = preg_replace('/^\\/+/', '', $filename); + $fileName = ltrim($fileName, '/'); - return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename); + return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName); } - /** - * Create and send data descriptor footer for this file. - * - * @return void - */ - public function addFileFooter(): void + private function checkEncoding(): void { - if ($this->bits & self::BIT_ZERO_HEADER) { - // compressed and uncompressed size - $sizeFormat = 'V'; - if ($this->zip->opt->isEnableZip64()) { - $sizeFormat = 'P'; + // 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 + ); } - $fields = [ - ['V', ZipStream::DATA_DESCRIPTOR_SIGNATURE], - ['V', $this->crc], // CRC32 - [$sizeFormat, $this->zlen], // Length of compressed data - [$sizeFormat, $this->len], // Length of original data - ]; - $footer = ZipStream::packFields($fields); - $this->zip->send($footer); - } else { - $footer = ''; + $this->compressedSize += strlen($data); + + if ($send) { + ($this->send)($data); + } } - $this->totalLength = $this->hlen->add($this->zlen)->add(Bigint::init(strlen($footer))); - $this->zip->addToCdr($this); + + if ($this->exactSize && $this->uncompressedSize !== $this->exactSize) { + throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize); + } + + $this->crc = hexdec(hash_final($hash)); } - public function processStream(StreamInterface $stream): void + private function compressionInit(): ?DeflateContext { - $this->zlen = new Bigint(); - $this->len = new Bigint(); + switch($this->compressionMethod) { + case CompressionMethod::STORE: + // Noting to do + return null; + case CompressionMethod::DEFLATE: + $deflateContext = deflate_init( + ZLIB_ENCODING_RAW, + ['level' => $this->deflateLevel] + ); - if ($this->zip->opt->isZeroHeader()) { - $this->processStreamWithZeroHeader($stream); - } else { - $this->processStreamWithComputedHeader($stream); + 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 } } - /** - * Send CDR record for specified file. - * - * @return string - */ - public function getCdrFile(): string + private function getCdrFile(): string { - $name = static::filterFilename($this->name); - - // get attributes - $comment = $this->opt->getComment(); - - // get dos timestamp - $time = static::dosTime($this->opt->getTime()->getTimestamp()); - $footer = $this->buildZip64ExtraBlock(); - $fields = [ - ['V', ZipStream::CDR_FILE_SIGNATURE], // Central file header signature - ['v', ZipStream::ZIP_VERSION_MADE_BY], // Made by version - ['v', $this->version->getValue()], // Extract by version - ['v', $this->bits], // General purpose bit flags - data descriptor flag set - ['v', $this->method->getValue()], // Compression method - ['V', $time], // Timestamp (DOS Format) - ['V', $this->crc], // CRC32 - ['V', $this->zlen->getLowFF()], // Compressed Data Length - ['V', $this->len->getLowFF()], // Original Data Length - ['v', strlen($name)], // Length of filename - ['v', strlen($footer)], // Extra data len (see above) - ['v', strlen($comment)], // Length of comment - ['v', 0], // Disk number - ['v', 0], // Internal File Attributes - ['V', 32], // External File Attributes - ['V', $this->ofs->getLowFF()], // Relative offset of local header - ]; - - // pack fields, then append name and comment - $header = ZipStream::packFields($fields); - - return $header . $name . $footer . $comment; + 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, + ); } - /** - * @return Bigint - */ - public function getTotalLength(): Bigint + private function isSimulation(): bool { - return $this->totalLength; - } - - /** - * Convert a UNIX timestamp to a DOS timestamp. - * - * @param int $when - * @return int DOS Timestamp - */ - final protected static function dosTime(int $when): int - { - // get date array for timestamp - $d = getdate($when); - - // set lower-bound on dates - if ($d['year'] < 1980) { - $d = [ - 'year' => 1980, - 'mon' => 1, - 'mday' => 1, - 'hours' => 0, - 'minutes' => 0, - 'seconds' => 0, - ]; - } - - // remove extra years from 1980 - $d['year'] -= 1980; - - // return date string - return - ($d['year'] << 25) | - ($d['mon'] << 21) | - ($d['mday'] << 16) | - ($d['hours'] << 11) | - ($d['minutes'] << 5) | - ($d['seconds'] >> 1); - } - - protected function buildZip64ExtraBlock(bool $force = false): string - { - $fields = []; - if ($this->len->isOver32($force)) { - $fields[] = ['P', $this->len]; // Length of original data - } - - if ($this->len->isOver32($force)) { - $fields[] = ['P', $this->zlen]; // Length of compressed data - } - - if ($this->ofs->isOver32()) { - $fields[] = ['P', $this->ofs]; // Offset of local header record - } - - if (!empty($fields)) { - if (!$this->zip->opt->isEnableZip64()) { - throw new OverflowException(); - } - - array_unshift( - $fields, - ['v', 0x0001], // 64 bit extension - ['v', count($fields) * 8] // Length of data block - ); - $this->version = Version::ZIP64(); - } - - if ($this->bits & self::BIT_EFS_UTF8) { - // Put the tricky entry to - // force Linux unzip to lookup EFS flag. - $fields[] = ['v', 0x5653]; // Choose 'ZS' for proprietary usage - $fields[] = ['v', 0x0000]; // zero length - } - - return ZipStream::packFields($fields); - } - - protected function processStreamWithZeroHeader(StreamInterface $stream): void - { - $this->bits |= self::BIT_ZERO_HEADER; - $this->addFileHeader(); - $this->readStream($stream, self::COMPUTE | self::SEND); - $this->addFileFooter(); - } - - protected function readStream(StreamInterface $stream, ?int $options = null): void - { - $this->deflateInit(); - $total = 0; - $size = $this->opt->getSize(); - while (!$stream->eof() && ($size === 0 || $total < $size)) { - $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE); - $total += strlen($data); - if ($size > 0 && $total > $size) { - $data = substr($data, 0, strlen($data)-($total - $size)); - } - $this->deflateData($stream, $data, $options); - if ($options & self::SEND) { - $this->zip->send($data); - } - } - $this->deflateFinish($options); - } - - protected function deflateInit(): void - { - $hash = hash_init(self::HASH_ALGORITHM); - $this->hash = $hash; - if ($this->method->equals(Method::DEFLATE())) { - $this->deflate = deflate_init( - ZLIB_ENCODING_RAW, - ['level' => $this->opt->getDeflateLevel()] - ); - } - } - - protected function deflateData(StreamInterface $stream, string &$data, ?int $options = null): void - { - if ($options & self::COMPUTE) { - $this->len = $this->len->add(Bigint::init(strlen($data))); - hash_update($this->hash, $data); - } - if ($this->deflate) { - $data = deflate_add( - $this->deflate, - $data, - $stream->eof() - ? ZLIB_FINISH - : ZLIB_NO_FLUSH - ); - } - if ($options & self::COMPUTE) { - $this->zlen = $this->zlen->add(Bigint::init(strlen($data))); - } - } - - protected function deflateFinish(?int $options = null): void - { - if ($options & self::COMPUTE) { - $this->crc = hexdec(hash_final($this->hash)); - } - } - - protected function processStreamWithComputedHeader(StreamInterface $stream): void - { - $this->readStream($stream, self::COMPUTE); - $stream->rewind(); - - $this->addFileHeader(); - $this->readStream($stream, self::SEND); - $this->addFileFooter(); + return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT; } } diff --git a/lib/zipstream/src/GeneralPurposeBitFlag.php b/lib/zipstream/src/GeneralPurposeBitFlag.php new file mode 100644 index 00000000000..23a66d8899f --- /dev/null +++ b/lib/zipstream/src/GeneralPurposeBitFlag.php @@ -0,0 +1,89 @@ +value), + new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)), + new PackField(format: 'V', value: $crc32UncompressedData), + new PackField(format: 'V', value: $compressedSize), + new PackField(format: 'V', value: $uncompressedSize), + new PackField(format: 'v', value: strlen($fileName)), + new PackField(format: 'v', value: strlen($extraField)), + ) . $fileName . $extraField; + } +} diff --git a/lib/zipstream/src/OperationMode.php b/lib/zipstream/src/OperationMode.php new file mode 100644 index 00000000000..dd650f0702f --- /dev/null +++ b/lib/zipstream/src/OperationMode.php @@ -0,0 +1,35 @@ + 4 GB or file count > 64k) - * - * @var bool - */ - private $enableZip64 = true; - - /** - * Enable streaming files with single read where - * general purpose bit 3 indicates local file header - * contain zero values in crc and size fields, - * these appear only after file contents - * in data descriptor block. - * - * @var bool - */ - private $zeroHeader = false; - - /** - * Enable reading file stat for determining file size. - * When a 32-bit system reads file size that is - * over 2 GB, invalid value appears in file size - * due to integer overflow. Should be disabled on - * 32-bit systems with method addFileFromPath - * if any file may exceed 2 GB. In this case file - * will be read in blocks and correct size will be - * determined from content. - * - * @var bool - */ - private $statFiles = true; - - /** - * Enable flush after every write to output stream. - * @var bool - */ - private $flushOutput = false; - - /** - * HTTP Content-Disposition. Defaults to - * 'attachment', where - * FILENAME is the specified filename. - * - * Note that this does nothing if you are - * not sending HTTP headers. - * - * @var string - */ - private $contentDisposition = 'attachment'; - - /** - * Note that this does nothing if you are - * not sending HTTP headers. - * - * @var string - */ - private $contentType = 'application/x-zip'; - - /** - * @var int - */ - private $deflateLevel = 6; - - /** - * @var StreamInterface|resource - */ - private $outputStream; - - /** - * Options constructor. - */ - public function __construct() - { - $this->largeFileMethod = Method::STORE(); - $this->outputStream = fopen('php://output', 'wb'); - } - - public function getComment(): string - { - return $this->comment; - } - - public function setComment(string $comment): void - { - $this->comment = $comment; - } - - public function getLargeFileSize(): int - { - return $this->largeFileSize; - } - - public function setLargeFileSize(int $largeFileSize): void - { - $this->largeFileSize = $largeFileSize; - } - - public function getLargeFileMethod(): Method - { - return $this->largeFileMethod; - } - - public function setLargeFileMethod(Method $largeFileMethod): void - { - $this->largeFileMethod = $largeFileMethod; - } - - public function isSendHttpHeaders(): bool - { - return $this->sendHttpHeaders; - } - - public function setSendHttpHeaders(bool $sendHttpHeaders): void - { - $this->sendHttpHeaders = $sendHttpHeaders; - } - - public function getHttpHeaderCallback(): callable - { - return $this->httpHeaderCallback; - } - - public function setHttpHeaderCallback(callable $httpHeaderCallback): void - { - $this->httpHeaderCallback = $httpHeaderCallback; - } - - public function isEnableZip64(): bool - { - return $this->enableZip64; - } - - public function setEnableZip64(bool $enableZip64): void - { - $this->enableZip64 = $enableZip64; - } - - public function isZeroHeader(): bool - { - return $this->zeroHeader; - } - - public function setZeroHeader(bool $zeroHeader): void - { - $this->zeroHeader = $zeroHeader; - } - - public function isFlushOutput(): bool - { - return $this->flushOutput; - } - - public function setFlushOutput(bool $flushOutput): void - { - $this->flushOutput = $flushOutput; - } - - public function isStatFiles(): bool - { - return $this->statFiles; - } - - public function setStatFiles(bool $statFiles): void - { - $this->statFiles = $statFiles; - } - - public function getContentDisposition(): string - { - return $this->contentDisposition; - } - - public function setContentDisposition(string $contentDisposition): void - { - $this->contentDisposition = $contentDisposition; - } - - public function getContentType(): string - { - return $this->contentType; - } - - public function setContentType(string $contentType): void - { - $this->contentType = $contentType; - } - - /** - * @return StreamInterface|resource - */ - public function getOutputStream() - { - return $this->outputStream; - } - - /** - * @param StreamInterface|resource $outputStream - */ - public function setOutputStream($outputStream): void - { - $this->outputStream = $outputStream; - } - - /** - * @return int - */ - public function getDeflateLevel(): int - { - return $this->deflateLevel; - } - - /** - * @param int $deflateLevel - */ - public function setDeflateLevel(int $deflateLevel): void - { - $this->deflateLevel = $deflateLevel; - } -} diff --git a/lib/zipstream/src/Option/File.php b/lib/zipstream/src/Option/File.php deleted file mode 100644 index 37e37ced608..00000000000 --- a/lib/zipstream/src/Option/File.php +++ /dev/null @@ -1,122 +0,0 @@ -deflateLevel = $this->deflateLevel ?: $archiveOptions->getDeflateLevel(); - $this->time = $this->time ?: new DateTime(); - } - - /** - * @return string - */ - public function getComment(): string - { - return $this->comment; - } - - /** - * @param string $comment - */ - public function setComment(string $comment): void - { - $this->comment = $comment; - } - - /** - * @return Method - */ - public function getMethod(): Method - { - return $this->method ?: Method::DEFLATE(); - } - - /** - * @param Method $method - */ - public function setMethod(Method $method): void - { - $this->method = $method; - } - - /** - * @return int - */ - public function getDeflateLevel(): int - { - return $this->deflateLevel ?: Archive::DEFAULT_DEFLATE_LEVEL; - } - - /** - * @param int $deflateLevel - */ - public function setDeflateLevel(int $deflateLevel): void - { - $this->deflateLevel = $deflateLevel; - } - - /** - * @return DateTimeInterface - */ - public function getTime(): DateTimeInterface - { - return $this->time; - } - - /** - * @param DateTimeInterface $time - */ - public function setTime(DateTimeInterface $time): void - { - $this->time = $time; - } - - /** - * @return int - */ - public function getSize(): int - { - return $this->size; - } - - /** - * @param int $size - */ - public function setSize(int $size): void - { - $this->size = $size; - } -} diff --git a/lib/zipstream/src/Option/Method.php b/lib/zipstream/src/Option/Method.php deleted file mode 100644 index 0dfce1baf20..00000000000 --- a/lib/zipstream/src/Option/Method.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ -class Method extends Enum -{ - public const STORE = 0x00; - - public const DEFLATE = 0x08; -} diff --git a/lib/zipstream/src/Option/Version.php b/lib/zipstream/src/Option/Version.php deleted file mode 100644 index f3daa852d00..00000000000 --- a/lib/zipstream/src/Option/Version.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ -class Version extends Enum -{ - public const STORE = 0x000A; // 1.00 - - public const DEFLATE = 0x0014; // 2.00 - - public const ZIP64 = 0x002D; // 4.50 -} diff --git a/lib/zipstream/src/PackField.php b/lib/zipstream/src/PackField.php new file mode 100644 index 00000000000..3370dd8d0ee --- /dev/null +++ b/lib/zipstream/src/PackField.php @@ -0,0 +1,57 @@ +format; + }, ''); + + $args = array_map(function (self $field) { + switch($field->format) { + case 'V': + if ($field->value > self::MAX_V) { + throw new RuntimeException(print_r($field->value, true) . ' is larger than 32 bits'); + } + break; + case 'v': + if ($field->value > self::MAX_v) { + throw new RuntimeException(print_r($field->value, true) . ' is larger than 16 bits'); + } + break; + case 'P': break; + default: + break; + } + + return $field->value; + }, $fields); + + return pack($fmt, ...$args); + } +} diff --git a/lib/zipstream/src/Stream.php b/lib/zipstream/src/Stream.php deleted file mode 100644 index d80e70f6a5f..00000000000 --- a/lib/zipstream/src/Stream.php +++ /dev/null @@ -1,265 +0,0 @@ -stream = $stream; - } - - /** - * Reads all data from the stream into a string, from the beginning to end. - * - * This method MUST attempt to seek to the beginning of the stream before - * reading data and read the stream until the end is reached. - * - * Warning: This could attempt to load a large amount of data into memory. - * - * This method MUST NOT raise an exception in order to conform with PHP's - * string casting operations. - * - * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring - * @return string - */ - public function __toString(): string - { - try { - $this->seek(0); - } catch (RuntimeException $e) { - } - return (string) stream_get_contents($this->stream); - } - - /** - * Closes the stream and any underlying resources. - * - * @return void - */ - public function close(): void - { - if (is_resource($this->stream)) { - fclose($this->stream); - } - $this->detach(); - } - - /** - * Separates any underlying resources from the stream. - * - * After the stream has been detached, the stream is in an unusable state. - * - * @return resource|null Underlying PHP stream, if any - */ - public function detach() - { - $result = $this->stream; - $this->stream = null; - return $result; - } - - /** - * Seek to a position in the stream. - * - * @link http://www.php.net/manual/en/function.fseek.php - * @param int $offset Stream offset - * @param int $whence Specifies how the cursor position will be calculated - * based on the seek offset. Valid values are identical to the built-in - * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to - * offset bytes SEEK_CUR: Set position to current location plus offset - * SEEK_END: Set position to end-of-stream plus offset. - * @throws RuntimeException on failure. - */ - public function seek($offset, $whence = SEEK_SET): void - { - if (!$this->isSeekable()) { - throw new RuntimeException(); - } - if (fseek($this->stream, $offset, $whence) !== 0) { - throw new RuntimeException(); - } - } - - /** - * Returns whether or not the stream is seekable. - * - * @return bool - */ - public function isSeekable(): bool - { - return (bool)$this->getMetadata('seekable'); - } - - /** - * Get stream metadata as an associative array or retrieve a specific key. - * - * The keys returned are identical to the keys returned from PHP's - * stream_get_meta_data() function. - * - * @link http://php.net/manual/en/function.stream-get-meta-data.php - * @param string $key Specific metadata to retrieve. - * @return array|mixed|null Returns an associative array if no key is - * provided. Returns a specific key value if a key is provided and the - * value is found, or null if the key is not found. - */ - public function getMetadata($key = null) - { - $metadata = stream_get_meta_data($this->stream); - return $key !== null ? @$metadata[$key] : $metadata; - } - - /** - * Get the size of the stream if known. - * - * @return int|null Returns the size in bytes if known, or null if unknown. - */ - public function getSize(): ?int - { - $stats = fstat($this->stream); - return $stats['size']; - } - - /** - * Returns the current position of the file read/write pointer - * - * @return int Position of the file pointer - * @throws RuntimeException on error. - */ - public function tell(): int - { - $position = ftell($this->stream); - if ($position === false) { - throw new RuntimeException(); - } - return $position; - } - - /** - * Returns true if the stream is at the end of the stream. - * - * @return bool - */ - public function eof(): bool - { - return feof($this->stream); - } - - /** - * Seek to the beginning of the stream. - * - * If the stream is not seekable, this method will raise an exception; - * otherwise, it will perform a seek(0). - * - * @see seek() - * @link http://www.php.net/manual/en/function.fseek.php - * @throws RuntimeException on failure. - */ - public function rewind(): void - { - $this->seek(0); - } - - /** - * Write data to the stream. - * - * @param string $string The string that is to be written. - * @return int Returns the number of bytes written to the stream. - * @throws RuntimeException on failure. - */ - public function write($string): int - { - if (!$this->isWritable()) { - throw new RuntimeException(); - } - if (fwrite($this->stream, $string) === false) { - throw new RuntimeException(); - } - return mb_strlen($string); - } - - /** - * Returns whether or not the stream is writable. - * - * @return bool - */ - public function isWritable(): bool - { - $mode = $this->getMetadata('mode'); - if (!is_string($mode)) { - throw new RuntimeException('Could not get stream mode from metadata!'); - } - return preg_match('/[waxc+]/', $mode) === 1; - } - - /** - * Read data from the stream. - * - * @param int $length Read up to $length bytes from the object and return - * them. Fewer than $length bytes may be returned if underlying stream - * call returns fewer bytes. - * @return string Returns the data read from the stream, or an empty string - * if no bytes are available. - * @throws RuntimeException if an error occurs. - */ - public function read($length): string - { - if (!$this->isReadable()) { - throw new RuntimeException(); - } - $result = fread($this->stream, $length); - if ($result === false) { - throw new RuntimeException(); - } - return $result; - } - - /** - * Returns whether or not the stream is readable. - * - * @return bool - */ - public function isReadable(): bool - { - $mode = $this->getMetadata('mode'); - if (!is_string($mode)) { - throw new RuntimeException('Could not get stream mode from metadata!'); - } - return preg_match('/[r+]/', $mode) === 1; - } - - /** - * Returns the remaining contents in a string - * - * @return string - * @throws RuntimeException if unable to read or an error occurs while - * reading. - */ - public function getContents(): string - { - if (!$this->isReadable()) { - throw new RuntimeException(); - } - $result = stream_get_contents($this->stream); - if ($result === false) { - throw new RuntimeException(); - } - return $result; - } -} diff --git a/lib/zipstream/src/Time.php b/lib/zipstream/src/Time.php new file mode 100644 index 00000000000..4bfba3cc53d --- /dev/null +++ b/lib/zipstream/src/Time.php @@ -0,0 +1,45 @@ +getTimestamp() < $dosMinimumDate->getTimestamp()) { + throw new DosTimeOverflowException(dateTime: $dateTime); + } + + $dateTime = DateTimeImmutable::createFromInterface($dateTime)->sub(new DateInterval('P1980Y')); + + ['year' => $year, + 'mon' => $month, + 'mday' => $day, + 'hours' => $hour, + 'minutes' => $minute, + 'seconds' => $second + ] = getdate($dateTime->getTimestamp()); + + return + ($year << 25) | + ($month << 21) | + ($day << 16) | + ($hour << 11) | + ($minute << 5) | + ($second >> 1); + } +} diff --git a/lib/zipstream/src/Version.php b/lib/zipstream/src/Version.php new file mode 100644 index 00000000000..c014f8a104b --- /dev/null +++ b/lib/zipstream/src/Version.php @@ -0,0 +1,12 @@ +addFile('some_file.gif', $data); + * ```php + * // add first file + * $zip->addFile(fileName: 'world.txt', data: 'Hello World'); * - * * add second file - * $data = file_get_contents('some_file.gif'); - * $zip->addFile('another_file.png', $data); + * // add second file + * $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon'); + * ``` * * 3. Finish the zip stream: * - * $zip->finish(); + * ```php + * $zip->finish(); + * ``` * * You can also add an archive comment, add comments to individual files, * and adjust the timestamp of files. See the API documentation for each * method below for additional information. * - * Example: + * ## Example * - * // create a new zip stream object - * $zip = new ZipStream('some_files.zip'); + * ```php + * // create a new zip stream object + * $zip = new ZipStream(outputName: 'some_files.zip'); * - * // list of local files - * $files = array('foo.txt', 'bar.jpg'); + * // list of local files + * $files = array('foo.txt', 'bar.jpg'); * - * // read and add each file to the archive - * foreach ($files as $path) - * $zip->addFile($path, file_get_contents($path)); + * // read and add each file to the archive + * foreach ($files as $path) + * $zip->addFileFormPath(fileName: $path, $path); * - * // write archive footer to stream - * $zip->finish(); + * // write archive footer to stream + * $zip->finish(); + * ``` */ class ZipStream { @@ -80,529 +89,776 @@ class ZipStream * Here we are using 6 for the OS, indicating OS/2 H.P.F.S. * to prevent file permissions issues upon extract (see #84) * 0x603 is 00000110 00000011 in binary, so 6 and 3 + * + * @internal */ public const ZIP_VERSION_MADE_BY = 0x603; - /** - * The following signatures end with 0x4b50, which in ASCII is PK, - * the initials of the inventor Phil Katz. - * See https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers - */ - public const FILE_HEADER_SIGNATURE = 0x04034b50; + private bool $ready = true; - public const CDR_FILE_SIGNATURE = 0x02014b50; - - public const CDR_EOF_SIGNATURE = 0x06054b50; - - public const DATA_DESCRIPTOR_SIGNATURE = 0x08074b50; - - public const ZIP64_CDR_EOF_SIGNATURE = 0x06064b50; - - public const ZIP64_CDR_LOCATOR_SIGNATURE = 0x07064b50; + private int $offset = 0; /** - * Global Options - * - * @var ArchiveOptions + * @var string[] */ - public $opt; + private array $centralDirectoryRecords = []; /** - * @var array + * @var resource */ - public $files = []; + private $outputStream; + + private readonly Closure $httpHeaderCallback; /** - * @var Bigint + * @var File[] */ - public $cdr_ofs; - - /** - * @var Bigint - */ - public $ofs; - - /** - * @var bool - */ - protected $need_headers; - - /** - * @var null|String - */ - protected $output_name; + private array $recordedSimulation = []; /** * Create a new ZipStream object. * - * Parameters: + * ##### Examples * - * @param String $name - Name of output file (optional). - * @param ArchiveOptions $opt - Archive Options + * ```php + * // create a new zip file named 'foo.zip' + * $zip = new ZipStream(outputName: 'foo.zip'); * - * Large File Support: + * // create a new zip file named 'bar.zip' with a comment + * $zip = new ZipStream( + * outputName: 'bar.zip', + * comment: 'this is a comment for the zip file.', + * ); + * ``` * - * By default, the method addFileFromPath() will send send files - * larger than 20 megabytes along raw rather than attempting to - * compress them. You can change both the maximum size and the - * compression behavior using the largeFile* options above, with the - * following caveats: + * @param OperationMode $operationMode + * The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes. + * For details see the `OperationMode` documentation. * - * * For "small" files (e.g. files smaller than largeFileSize), the - * memory use can be up to twice that of the actual file. In other - * words, adding a 10 megabyte file to the archive could potentially - * occupy 20 megabytes of memory. + * Default to `NORMAL`. * - * * Enabling compression on large files (e.g. files larger than - * large_file_size) is extremely slow, because ZipStream has to pass - * over the large file once to calculate header information, and then - * again to compress and send the actual data. + * @param string $comment + * Archive Level Comment * - * Examples: + * @param StreamInterface|resource|null $outputStream + * Override the output of the archive to a different target. * - * // create a new zip file named 'foo.zip' - * $zip = new ZipStream('foo.zip'); + * By default the archive is sent to `STDOUT`. * - * // create a new zip file named 'bar.zip' with a comment - * $opt->setComment = 'this is a comment for the zip file.'; - * $zip = new ZipStream('bar.zip', $opt); + * @param CompressionMethod $defaultCompressionMethod + * How to handle file compression. Legal values are + * `CompressionMethod::DEFLATE` (the default), or + * `CompressionMethod::STORE`. `STORE` sends the file raw and is + * significantly faster, while `DEFLATE` compresses the file and + * is much, much slower. * - * Notes: + * @param int $defaultDeflateLevel + * Default deflation level. Only relevant if `compressionMethod` + * is `DEFLATE`. * - * In order to let this library send HTTP headers, a filename must be given - * _and_ the option `sendHttpHeaders` must be `true`. This behavior is to - * allow software to send its own headers (including the filename), and - * still use this library. + * See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters) + * + * @param bool $enableZip64 + * Enable Zip64 extension, supporting very large + * archives (any size > 4 GB or file count > 64k) + * + * @param bool $defaultEnableZeroHeader + * Enable streaming files with single read. + * + * When the zero header is set, the file is streamed into the output + * and the size & checksum are added at the end of the file. This is the + * fastest method and uses the least memory. Unfortunately not all + * ZIP clients fully support this and can lead to clients reporting + * the generated ZIP files as corrupted in combination with other + * circumstances. (Zip64 enabled, using UTF8 in comments / names etc.) + * + * When the zero header is not set, the length & checksum need to be + * defined before the file is actually added. To prevent loading all + * the data into memory, the data has to be read twice. If the data + * which is added is not seekable, this call will fail. + * + * @param bool $sendHttpHeaders + * Boolean indicating whether or not to send + * the HTTP headers for this file. + * + * @param ?Closure $httpHeaderCallback + * The method called to send HTTP headers + * + * @param string|null $outputName + * The name of the created archive. + * + * Only relevant if `$sendHttpHeaders = true`. + * + * @param string $contentDisposition + * HTTP Content-Disposition + * + * Only relevant if `sendHttpHeaders = true`. + * + * @param string $contentType + * HTTP Content Type + * + * Only relevant if `sendHttpHeaders = true`. + * + * @param bool $flushOutput + * Enable flush after every write to output stream. + * + * @return self */ - public function __construct(?string $name = null, ?ArchiveOptions $opt = null) - { - $this->opt = $opt ?: new ArchiveOptions(); - - $this->output_name = $name; - $this->need_headers = $name && $this->opt->isSendHttpHeaders(); - - $this->cdr_ofs = new Bigint(); - $this->ofs = new Bigint(); + public function __construct( + private OperationMode $operationMode = OperationMode::NORMAL, + private readonly string $comment = '', + $outputStream = null, + private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE, + private readonly int $defaultDeflateLevel = 6, + private readonly bool $enableZip64 = true, + private readonly bool $defaultEnableZeroHeader = true, + private bool $sendHttpHeaders = true, + ?Closure $httpHeaderCallback = null, + private readonly ?string $outputName = null, + private readonly string $contentDisposition = 'attachment', + private readonly string $contentType = 'application/x-zip', + private bool $flushOutput = false, + ) { + $this->outputStream = self::normalizeStream($outputStream); + $this->httpHeaderCallback = $httpHeaderCallback ?? header(...); } /** - * addFile - * * Add a file to the archive. * - * @param String $name - path of file in archive (including directory). - * @param String $data - contents of file - * @param FileOptions $options + * ##### File Options * - * File Options: - * time - Last-modified timestamp (seconds since the epoch) of - * this file. Defaults to the current time. - * comment - Comment related to this file. - * method - Storage method for file ("store" or "deflate") + * See {@see addFileFromPsr7Stream()} * - * Examples: + * ##### Examples * - * // add a file named 'foo.txt' - * $data = file_get_contents('foo.txt'); - * $zip->addFile('foo.txt', $data); + * ```php + * // add a file named 'world.txt' + * $zip->addFile(fileName: 'world.txt', data: 'Hello World!'); * - * // add a file named 'bar.jpg' with a comment and a last-modified - * // time of two hours ago - * $data = file_get_contents('bar.jpg'); - * $opt->setTime = time() - 2 * 3600; - * $opt->setComment = 'this is a comment about bar.jpg'; - * $zip->addFile('bar.jpg', $data, $opt); + * // add a file named 'bar.jpg' with a comment and a last-modified + * // time of two hours ago + * $zip->addFile( + * fileName: 'bar.jpg', + * data: $data, + * comment: 'this is a comment about bar.jpg', + * lastModificationDateTime: new DateTime('2 hours ago'), + * ); + * ``` + * + * @param string $data + * + * contents of file */ - public function addFile(string $name, string $data, ?FileOptions $options = null): void - { - $options = $options ?: new FileOptions(); - $options->defaultTo($this->opt); - - $file = new File($this, $name, $options); - $file->processData($data); + public function addFile( + string $fileName, + string $data, + string $comment = '', + ?CompressionMethod $compressionMethod = null, + ?int $deflateLevel = null, + ?DateTimeInterface $lastModificationDateTime = null, + ?int $maxSize = null, + ?int $exactSize = null, + ?bool $enableZeroHeader = null, + ): void { + $this->addFileFromCallback( + fileName: $fileName, + callback: fn () => $data, + comment: $comment, + compressionMethod: $compressionMethod, + deflateLevel: $deflateLevel, + lastModificationDateTime: $lastModificationDateTime, + maxSize: $maxSize, + exactSize: $exactSize, + enableZeroHeader: $enableZeroHeader, + ); } /** - * addFileFromPath - * * Add a file at path to the archive. * - * Note that large files may be compressed differently than smaller - * files; see the "Large File Support" section above for more - * information. + * ##### File Options * - * @param String $name - name of file in archive (including directory path). - * @param String $path - path to file on disk (note: paths should be encoded using - * UNIX-style forward slashes -- e.g '/path/to/some/file'). - * @param FileOptions $options + * See {@see addFileFromPsr7Stream()} * - * File Options: - * time - Last-modified timestamp (seconds since the epoch) of - * this file. Defaults to the current time. - * comment - Comment related to this file. - * method - Storage method for file ("store" or "deflate") + * ###### Examples * - * Examples: + * ```php + * // add a file named 'foo.txt' from the local file '/tmp/foo.txt' + * $zip->addFileFromPath( + * fileName: 'foo.txt', + * path: '/tmp/foo.txt', + * ); * - * // add a file named 'foo.txt' from the local file '/tmp/foo.txt' - * $zip->addFileFromPath('foo.txt', '/tmp/foo.txt'); + * // add a file named 'bigfile.rar' from the local file + * // '/usr/share/bigfile.rar' with a comment and a last-modified + * // time of two hours ago + * $zip->addFile( + * fileName: 'bigfile.rar', + * path: '/usr/share/bigfile.rar', + * comment: 'this is a comment about bigfile.rar', + * lastModificationDateTime: new DateTime('2 hours ago'), + * ); + * ``` * - * // add a file named 'bigfile.rar' from the local file - * // '/usr/share/bigfile.rar' with a comment and a last-modified - * // time of two hours ago - * $path = '/usr/share/bigfile.rar'; - * $opt->setTime = time() - 2 * 3600; - * $opt->setComment = 'this is a comment about bar.jpg'; - * $zip->addFileFromPath('bigfile.rar', $path, $opt); - * - * @return void * @throws \ZipStream\Exception\FileNotFoundException * @throws \ZipStream\Exception\FileNotReadableException */ - public function addFileFromPath(string $name, string $path, ?FileOptions $options = null): void - { - $options = $options ?: new FileOptions(); - $options->defaultTo($this->opt); + public function addFileFromPath( + /** + * name of file in archive (including directory path). + */ + string $fileName, - $file = new File($this, $name, $options); - $file->processPath($path); + /** + * path to file on disk (note: paths should be encoded using + * UNIX-style forward slashes -- e.g '/path/to/some/file'). + */ + string $path, + string $comment = '', + ?CompressionMethod $compressionMethod = null, + ?int $deflateLevel = null, + ?DateTimeInterface $lastModificationDateTime = null, + ?int $maxSize = null, + ?int $exactSize = null, + ?bool $enableZeroHeader = null, + ): void { + if (!is_readable($path)) { + if (!file_exists($path)) { + throw new FileNotFoundException($path); + } + throw new FileNotReadableException($path); + } + + if ($fileTime = filemtime($path)) { + $lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime); + } + + $this->addFileFromCallback( + fileName: $fileName, + callback: function () use ($path) { + + $stream = fopen($path, 'rb'); + + if (!$stream) { + // @codeCoverageIgnoreStart + throw new ResourceActionException('fopen'); + // @codeCoverageIgnoreEnd + } + + return $stream; + }, + comment: $comment, + compressionMethod: $compressionMethod, + deflateLevel: $deflateLevel, + lastModificationDateTime: $lastModificationDateTime, + maxSize: $maxSize, + exactSize: $exactSize, + enableZeroHeader: $enableZeroHeader, + ); } /** - * addFileFromStream + * Add an open stream (resource) to the archive. * - * Add an open stream to the archive. + * ##### File Options * - * @param String $name - path of file in archive (including directory). - * @param resource $stream - contents of file as a stream resource - * @param FileOptions $options + * See {@see addFileFromPsr7Stream()} * - * File Options: - * time - Last-modified timestamp (seconds since the epoch) of - * this file. Defaults to the current time. - * comment - Comment related to this file. + * ##### Examples * - * Examples: + * ```php + * // create a temporary file stream and write text to it + * $filePointer = tmpfile(); + * fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.'); * - * // create a temporary file stream and write text to it - * $fp = tmpfile(); - * fwrite($fp, 'The quick brown fox jumped over the lazy dog.'); + * // add a file named 'streamfile.txt' from the content of the stream + * $archive->addFileFromStream( + * fileName: 'streamfile.txt', + * stream: $filePointer, + * ); + * ``` * - * // add a file named 'streamfile.txt' from the content of the stream - * $x->addFileFromStream('streamfile.txt', $fp); - * - * @return void + * @param resource $stream contents of file as a stream resource */ - public function addFileFromStream(string $name, $stream, ?FileOptions $options = null): void - { - $options = $options ?: new FileOptions(); - $options->defaultTo($this->opt); - - $file = new File($this, $name, $options); - $file->processStream(new Stream($stream)); + public function addFileFromStream( + string $fileName, + $stream, + string $comment = '', + ?CompressionMethod $compressionMethod = null, + ?int $deflateLevel = null, + ?DateTimeInterface $lastModificationDateTime = null, + ?int $maxSize = null, + ?int $exactSize = null, + ?bool $enableZeroHeader = null, + ): void { + $this->addFileFromCallback( + fileName: $fileName, + callback: fn () => $stream, + comment: $comment, + compressionMethod: $compressionMethod, + deflateLevel: $deflateLevel, + lastModificationDateTime: $lastModificationDateTime, + maxSize: $maxSize, + exactSize: $exactSize, + enableZeroHeader: $enableZeroHeader, + ); } /** - * addFileFromPsr7Stream - * * Add an open stream to the archive. * - * @param String $name - path of file in archive (including directory). - * @param StreamInterface $stream - contents of file as a stream resource - * @param FileOptions $options + * ##### Examples * - * File Options: - * time - Last-modified timestamp (seconds since the epoch) of - * this file. Defaults to the current time. - * comment - Comment related to this file. + * ```php + * $stream = $response->getBody(); + * // add a file named 'streamfile.txt' from the content of the stream + * $archive->addFileFromPsr7Stream( + * fileName: 'streamfile.txt', + * stream: $stream, + * ); + * ``` * - * Examples: + * @param string $fileName + * path of file in archive (including directory) * - * $stream = $response->getBody(); - * // add a file named 'streamfile.txt' from the content of the stream - * $x->addFileFromPsr7Stream('streamfile.txt', $stream); + * @param StreamInterface $stream + * contents of file as a stream resource * - * @return void + * @param string $comment + * ZIP comment for this file + * + * @param ?CompressionMethod $compressionMethod + * Override `defaultCompressionMethod` + * + * See {@see __construct()} + * + * @param ?int $deflateLevel + * Override `defaultDeflateLevel` + * + * See {@see __construct()} + * + * @param ?DateTimeInterface $lastModificationDateTime + * Set last modification time of file. + * + * Default: `now` + * + * @param ?int $maxSize + * Only read `maxSize` bytes from file. + * + * The file is considered done when either reaching `EOF` + * or the `maxSize`. + * + * @param ?int $exactSize + * Read exactly `exactSize` bytes from file. + * If `EOF` is reached before reading `exactSize` bytes, an error will be + * thrown. The parameter allows for faster size calculations if the `stream` + * does not support `fstat` size or is slow and otherwise known beforehand. + * + * @param ?bool $enableZeroHeader + * Override `defaultEnableZeroHeader` + * + * See {@see __construct()} */ public function addFileFromPsr7Stream( - string $name, + string $fileName, StreamInterface $stream, - ?FileOptions $options = null + string $comment = '', + ?CompressionMethod $compressionMethod = null, + ?int $deflateLevel = null, + ?DateTimeInterface $lastModificationDateTime = null, + ?int $maxSize = null, + ?int $exactSize = null, + ?bool $enableZeroHeader = null, ): void { - $options = $options ?: new FileOptions(); - $options->defaultTo($this->opt); - - $file = new File($this, $name, $options); - $file->processStream($stream); + $this->addFileFromCallback( + fileName: $fileName, + callback: fn () => $stream, + comment: $comment, + compressionMethod: $compressionMethod, + deflateLevel: $deflateLevel, + lastModificationDateTime: $lastModificationDateTime, + maxSize: $maxSize, + exactSize: $exactSize, + enableZeroHeader: $enableZeroHeader, + ); } /** - * finish + * Add a file based on a callback. * + * This is useful when you want to simulate a lot of files without keeping + * all of the file handles open at the same time. + * + * ##### Examples + * + * ```php + * foreach($files as $name => $size) { + * $archive->addFileFromPsr7Stream( + * fileName: 'streamfile.txt', + * exactSize: $size, + * callback: function() use($name): Psr\Http\Message\StreamInterface { + * $response = download($name); + * return $response->getBody(); + * } + * ); + * } + * ``` + * + * @param string $fileName + * path of file in archive (including directory) + * + * @param Closure $callback + * @psalm-param Closure(): (resource|StreamInterface|string) $callback + * A callback to get the file contents in the shape of a PHP stream, + * a Psr StreamInterface implementation, or a string. + * + * @param string $comment + * ZIP comment for this file + * + * @param ?CompressionMethod $compressionMethod + * Override `defaultCompressionMethod` + * + * See {@see __construct()} + * + * @param ?int $deflateLevel + * Override `defaultDeflateLevel` + * + * See {@see __construct()} + * + * @param ?DateTimeInterface $lastModificationDateTime + * Set last modification time of file. + * + * Default: `now` + * + * @param ?int $maxSize + * Only read `maxSize` bytes from file. + * + * The file is considered done when either reaching `EOF` + * or the `maxSize`. + * + * @param ?int $exactSize + * Read exactly `exactSize` bytes from file. + * If `EOF` is reached before reading `exactSize` bytes, an error will be + * thrown. The parameter allows for faster size calculations if the `stream` + * does not support `fstat` size or is slow and otherwise known beforehand. + * + * @param ?bool $enableZeroHeader + * Override `defaultEnableZeroHeader` + * + * See {@see __construct()} + */ + public function addFileFromCallback( + string $fileName, + Closure $callback, + string $comment = '', + ?CompressionMethod $compressionMethod = null, + ?int $deflateLevel = null, + ?DateTimeInterface $lastModificationDateTime = null, + ?int $maxSize = null, + ?int $exactSize = null, + ?bool $enableZeroHeader = null, + ): void { + $file = new File( + dataCallback: function () use ($callback, $maxSize) { + $data = $callback(); + + if(is_resource($data)) { + return $data; + } + + if($data instanceof StreamInterface) { + return StreamWrapper::getResource($data); + } + + + $stream = fopen('php://memory', 'rw+'); + if ($stream === false) { + // @codeCoverageIgnoreStart + throw new ResourceActionException('fopen'); + // @codeCoverageIgnoreEnd + } + if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) { + // @codeCoverageIgnoreStart + throw new ResourceActionException('fwrite', $stream); + // @codeCoverageIgnoreEnd + } elseif (fwrite($stream, $data) === false) { + // @codeCoverageIgnoreStart + throw new ResourceActionException('fwrite', $stream); + // @codeCoverageIgnoreEnd + } + if (rewind($stream) === false) { + // @codeCoverageIgnoreStart + throw new ResourceActionException('rewind', $stream); + // @codeCoverageIgnoreEnd + } + + return $stream; + + }, + send: $this->send(...), + recordSentBytes: $this->recordSentBytes(...), + operationMode: $this->operationMode, + fileName: $fileName, + startOffset: $this->offset, + compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod, + comment: $comment, + deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel, + lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(), + maxSize: $maxSize, + exactSize: $exactSize, + enableZip64: $this->enableZip64, + enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader, + ); + + if($this->operationMode !== OperationMode::NORMAL) { + $this->recordedSimulation[] = $file; + } + + $this->centralDirectoryRecords[] = $file->process(); + } + + /** + * Add a directory to the archive. + * + * ##### File Options + * + * See {@see addFileFromPsr7Stream()} + * + * ##### Examples + * + * ```php + * // add a directory named 'world/' + * $zip->addFile(fileName: 'world/'); + * ``` + */ + public function addDirectory( + string $fileName, + string $comment = '', + ?DateTimeInterface $lastModificationDateTime = null, + ): void { + if (!str_ends_with($fileName, '/')) { + $fileName .= '/'; + } + + $this->addFile( + fileName: $fileName, + data: '', + comment: $comment, + compressionMethod: CompressionMethod::STORE, + deflateLevel: null, + lastModificationDateTime: $lastModificationDateTime, + maxSize: 0, + exactSize: 0, + enableZeroHeader: false, + ); + } + + /** + * Executes a previously calculated simulation. + * + * ##### Example + * + * ```php + * $zip = new ZipStream( + * outputName: 'foo.zip', + * operationMode: OperationMode::SIMULATE_STRICT, + * ); + * + * $zip->addFile('test.txt', 'Hello World'); + * + * $size = $zip->finish(); + * + * header('Content-Length: '. $size); + * + * $zip->executeSimulation(); + * ``` + */ + public function executeSimulation(): void + { + if($this->operationMode !== OperationMode::NORMAL) { + throw new RuntimeException('Zip simulation is not finished.'); + } + + foreach($this->recordedSimulation as $file) { + $this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process(); + } + + $this->finish(); + } + + /** * Write zip footer to stream. * - * Example: + * The clase is left in an unusable state after `finish`. * - * // add a list of files to the archive - * $files = array('foo.txt', 'bar.jpg'); - * foreach ($files as $path) - * $zip->addFile($path, file_get_contents($path)); + * ##### Example * - * // write footer to stream - * $zip->finish(); - * @return void - * - * @throws OverflowException + * ```php + * // write footer to stream + * $zip->finish(); + * ``` */ - public function finish(): void + public function finish(): int { + $centralDirectoryStartOffsetOnDisk = $this->offset; + $sizeOfCentralDirectory = 0; + // add trailing cdr file records - foreach ($this->files as $cdrFile) { - $this->send($cdrFile); - $this->cdr_ofs = $this->cdr_ofs->add(Bigint::init(strlen($cdrFile))); + foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) { + $this->send($centralDirectoryRecord); + $sizeOfCentralDirectory += strlen($centralDirectoryRecord); } // Add 64bit headers (if applicable) - if (count($this->files) >= 0xFFFF || - $this->cdr_ofs->isOver32() || - $this->ofs->isOver32()) { - if (!$this->opt->isEnableZip64()) { + if (count($this->centralDirectoryRecords) >= 0xFFFF || + $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF || + $sizeOfCentralDirectory > 0xFFFFFFFF) { + if (!$this->enableZip64) { throw new OverflowException(); } - $this->addCdr64Eof(); - $this->addCdr64Locator(); + $this->send(Zip64\EndOfCentralDirectory::generate( + versionMadeBy: self::ZIP_VERSION_MADE_BY, + versionNeededToExtract: Version::ZIP64->value, + numberOfThisDisk: 0, + numberOfTheDiskWithCentralDirectoryStart: 0, + numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords), + numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords), + sizeOfCentralDirectory: $sizeOfCentralDirectory, + centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk, + extensibleDataSector: '', + )); + + $this->send(Zip64\EndOfCentralDirectoryLocator::generate( + numberOfTheDiskWithZip64CentralDirectoryStart: 0x00, + zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory, + totalNumberOfDisks: 1, + )); } // add trailing cdr eof record - $this->addCdrEof(); + $numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF); + $this->send(EndOfCentralDirectory::generate( + numberOfThisDisk: 0x00, + numberOfTheDiskWithCentralDirectoryStart: 0x00, + numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries, + numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries, + sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF), + centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF), + zipFileComment: $this->comment, + )); + + $size = $this->offset; // The End $this->clear(); + + return $size; } /** - * Create a format string and argument list for pack(), then call - * pack() and return the result. - * - * @param array $fields - * @return string + * @param StreamInterface|resource|null $outputStream + * @return resource */ - public static function packFields(array $fields): string + private static function normalizeStream($outputStream) { - $fmt = ''; - $args = []; - - // populate format string and argument list - foreach ($fields as [$format, $value]) { - if ($format === 'P') { - $fmt .= 'VV'; - if ($value instanceof Bigint) { - $args[] = $value->getLow32(); - $args[] = $value->getHigh32(); - } else { - $args[] = $value; - $args[] = 0; - } - } else { - if ($value instanceof Bigint) { - $value = $value->getLow32(); - } - $fmt .= $format; - $args[] = $value; - } + if ($outputStream instanceof StreamInterface) { + return StreamWrapper::getResource($outputStream); } + if (is_resource($outputStream)) { + return $outputStream; + } + return fopen('php://output', 'wb'); + } - // prepend format string to argument list - array_unshift($args, $fmt); - - // build output string from header and compressed data - return pack(...$args); + /** + * Record sent bytes + */ + private function recordSentBytes(int $sentBytes): void + { + $this->offset += $sentBytes; } /** * Send string, sending HTTP headers if necessary. * Flush output after write if configure option is set. - * - * @param String $str - * @return void */ - public function send(string $str): void + private function send(string $data): void { - if ($this->need_headers) { + if (!$this->ready) { + throw new RuntimeException('Archive is already finished'); + } + + if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) { $this->sendHttpHeaders(); - } - $this->need_headers = false; - - $outputStream = $this->opt->getOutputStream(); - - if ($outputStream instanceof StreamInterface) { - $outputStream->write($str); - } else { - fwrite($outputStream, $str); + $this->sendHttpHeaders = false; } - if ($this->opt->isFlushOutput()) { - // flush output buffer if it is on and flushable - $status = ob_get_status(); - if (isset($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) { - ob_flush(); + $this->recordSentBytes(strlen($data)); + + if ($this->operationMode === OperationMode::NORMAL) { + if (fwrite($this->outputStream, $data) === false) { + throw new ResourceActionException('fwrite', $this->outputStream); } - // Flush system buffers after flushing userspace output buffer - flush(); + if ($this->flushOutput) { + // flush output buffer if it is on and flushable + $status = ob_get_status(); + if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) { + ob_flush(); + } + + // Flush system buffers after flushing userspace output buffer + flush(); + } } } - /** - * Is this file larger than large_file_size? - * - * @param string $path - * @return bool - */ - public function isLargeFile(string $path): bool - { - if (!$this->opt->isStatFiles()) { - return false; - } - $stat = stat($path); - return $stat['size'] > $this->opt->getLargeFileSize(); - } - - /** - * Save file attributes for trailing CDR record. - * - * @param File $file - * @return void - */ - public function addToCdr(File $file): void - { - $file->ofs = $this->ofs; - $this->ofs = $this->ofs->add($file->getTotalLength()); - $this->files[] = $file->getCdrFile(); - } - - /** - * Send ZIP64 CDR EOF (Central Directory Record End-of-File) record. - * - * @return void - */ - protected function addCdr64Eof(): void - { - $num_files = count($this->files); - $cdr_length = $this->cdr_ofs; - $cdr_offset = $this->ofs; - - $fields = [ - ['V', static::ZIP64_CDR_EOF_SIGNATURE], // ZIP64 end of central file header signature - ['P', 44], // Length of data below this header (length of block - 12) = 44 - ['v', static::ZIP_VERSION_MADE_BY], // Made by version - ['v', Version::ZIP64], // Extract by version - ['V', 0x00], // disk number - ['V', 0x00], // no of disks - ['P', $num_files], // no of entries on disk - ['P', $num_files], // no of entries in cdr - ['P', $cdr_length], // CDR size - ['P', $cdr_offset], // CDR offset - ]; - - $ret = static::packFields($fields); - $this->send($ret); - } - - /** + /** * Send HTTP headers for this stream. - * - * @return void */ - protected function sendHttpHeaders(): void + private function sendHttpHeaders(): void { // grab content disposition - $disposition = $this->opt->getContentDisposition(); + $disposition = $this->contentDisposition; - if ($this->output_name) { + if ($this->outputName) { // Various different browsers dislike various characters here. Strip them all for safety. - $safe_output = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->output_name)); + $safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName)); // Check if we need to UTF-8 encode the filename - $urlencoded = rawurlencode($safe_output); + $urlencoded = rawurlencode($safeOutput); $disposition .= "; filename*=UTF-8''{$urlencoded}"; } $headers = [ - 'Content-Type' => $this->opt->getContentType(), + 'Content-Type' => $this->contentType, 'Content-Disposition' => $disposition, 'Pragma' => 'public', 'Cache-Control' => 'public, must-revalidate', 'Content-Transfer-Encoding' => 'binary', ]; - $call = $this->opt->getHttpHeaderCallback(); foreach ($headers as $key => $val) { - $call("$key: $val"); + ($this->httpHeaderCallback)("$key: $val"); } } - /** - * Send ZIP64 CDR Locator (Central Directory Record Locator) record. - * - * @return void - */ - protected function addCdr64Locator(): void - { - $cdr_offset = $this->ofs->add($this->cdr_ofs); - - $fields = [ - ['V', static::ZIP64_CDR_LOCATOR_SIGNATURE], // ZIP64 end of central file header signature - ['V', 0x00], // Disc number containing CDR64EOF - ['P', $cdr_offset], // CDR offset - ['V', 1], // Total number of disks - ]; - - $ret = static::packFields($fields); - $this->send($ret); - } - - /** - * Send CDR EOF (Central Directory Record End-of-File) record. - * - * @return void - */ - protected function addCdrEof(): void - { - $num_files = count($this->files); - $cdr_length = $this->cdr_ofs; - $cdr_offset = $this->ofs; - - // grab comment (if specified) - $comment = $this->opt->getComment(); - - $fields = [ - ['V', static::CDR_EOF_SIGNATURE], // end of central file header signature - ['v', 0x00], // disk number - ['v', 0x00], // no of disks - ['v', min($num_files, 0xFFFF)], // no of entries on disk - ['v', min($num_files, 0xFFFF)], // no of entries in cdr - ['V', $cdr_length->getLowFF()], // CDR size - ['V', $cdr_offset->getLowFF()], // CDR offset - ['v', strlen($comment)], // Zip Comment size - ]; - - $ret = static::packFields($fields) . $comment; - $this->send($ret); - } - /** * Clear all internal variables. Note that the stream object is not * usable after this. - * - * @return void */ - protected function clear(): void + private function clear(): void { - $this->files = []; - $this->ofs = new Bigint(); - $this->cdr_ofs = new Bigint(); - $this->opt = new ArchiveOptions(); + $this->centralDirectoryRecords = []; + $this->offset = 0; + + if($this->operationMode === OperationMode::NORMAL) { + $this->ready = false; + $this->recordedSimulation = []; + } else { + $this->operationMode = OperationMode::NORMAL; + } } } diff --git a/lib/zipstream/src/Zs/ExtendedInformationExtraField.php b/lib/zipstream/src/Zs/ExtendedInformationExtraField.php new file mode 100644 index 00000000000..bf621bc09e3 --- /dev/null +++ b/lib/zipstream/src/Zs/ExtendedInformationExtraField.php @@ -0,0 +1,23 @@ +