123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- <?php
- declare(strict_types=1);
- namespace ZipStream;
- use HashContext;
- use Psr\Http\Message\StreamInterface;
- use ZipStream\Exception\FileNotFoundException;
- use ZipStream\Exception\FileNotReadableException;
- use ZipStream\Exception\OverflowException;
- use ZipStream\Option\File as FileOptions;
- use ZipStream\Option\Method;
- use ZipStream\Option\Version;
- class File
- {
- public const HASH_ALGORITHM = 'crc32b';
- public const BIT_ZERO_HEADER = 0x0008;
- public const BIT_EFS_UTF8 = 0x0800;
- public const COMPUTE = 1;
- public const SEND = 2;
- private const CHUNKED_READ_BLOCK_SIZE = 1048576;
- /**
- * @var string
- */
- public $name;
- /**
- * @var FileOptions
- */
- public $opt;
- /**
- * @var Bigint
- */
- public $len;
- /**
- * @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();
- }
- public function processPath(string $path): void
- {
- 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);
- } else {
- $this->method = $this->zip->opt->getLargeFileMethod();
- $stream = new DeflateStream(fopen($path, 'rb'));
- $this->processStream($stream);
- $stream->close();
- }
- }
- public function processData(string $data): void
- {
- $this->len = new Bigint(strlen($data));
- $this->crc = crc32($data);
- // compress data if needed
- if ($this->method->equals(Method::DEFLATE())) {
- $data = gzdeflate($data);
- }
- $this->zlen = new Bigint(strlen($data));
- $this->addFileHeader();
- $this->zip->send($data);
- $this->addFileFooter();
- }
- /**
- * Create and send zip header for this file.
- *
- * @return void
- * @throws \ZipStream\Exception\EncodingException
- */
- public function addFileHeader(): void
- {
- $name = static::filterFilename($this->name);
- // calculate name length
- $nameLength = strlen($name);
- // create dos timestamp
- $time = static::dosTime($this->opt->getTime()->getTimestamp());
- $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 ($this->method->equals(Method::DEFLATE())) {
- $this->version = Version::DEFLATE();
- }
- $force = (bool)($this->bits & self::BIT_ZERO_HEADER) &&
- $this->zip->opt->isEnableZip64();
- $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));
- }
- /**
- * 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
- {
- // strip leading slashes from file name
- // (fixes bug in windows archive viewer)
- $filename = preg_replace('/^\\/+/', '', $filename);
- return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename);
- }
- /**
- * Create and send data descriptor footer for this file.
- *
- * @return void
- */
- public function addFileFooter(): void
- {
- if ($this->bits & self::BIT_ZERO_HEADER) {
- // compressed and uncompressed size
- $sizeFormat = 'V';
- if ($this->zip->opt->isEnableZip64()) {
- $sizeFormat = 'P';
- }
- $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->totalLength = $this->hlen->add($this->zlen)->add(Bigint::init(strlen($footer)));
- $this->zip->addToCdr($this);
- }
- public function processStream(StreamInterface $stream): void
- {
- $this->zlen = new Bigint();
- $this->len = new Bigint();
- if ($this->zip->opt->isZeroHeader()) {
- $this->processStreamWithZeroHeader($stream);
- } else {
- $this->processStreamWithComputedHeader($stream);
- }
- }
- /**
- * Send CDR record for specified file.
- *
- * @return string
- */
- public 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 Bigint
- */
- public function getTotalLength(): Bigint
- {
- 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();
- // incremental compression with deflate_add
- // makes this second read unnecessary
- // but it is only available from PHP 7.0
- if (!$this->deflate && $stream instanceof DeflateStream && $this->method->equals(Method::DEFLATE())) {
- $stream->addDeflateFilter($this->opt);
- $this->zlen = new Bigint();
- while (!$stream->eof()) {
- $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
- $this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
- }
- $stream->rewind();
- }
- $this->addFileHeader();
- $this->readStream($stream, self::SEND);
- $this->addFileFooter();
- }
- }
|