Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403]

   1  <?php
   2  declare(strict_types=1);
   3  
   4  namespace ZipStream;
   5  
   6  use Psr\Http\Message\StreamInterface;
   7  use ZipStream\Exception\EncodingException;
   8  use ZipStream\Exception\FileNotFoundException;
   9  use ZipStream\Exception\FileNotReadableException;
  10  use ZipStream\Exception\OverflowException;
  11  use ZipStream\Option\File as FileOptions;
  12  use ZipStream\Option\Method;
  13  use ZipStream\Option\Version;
  14  
  15  class File
  16  {
  17      const HASH_ALGORITHM = 'crc32b';
  18  
  19      const BIT_ZERO_HEADER = 0x0008;
  20      const BIT_EFS_UTF8 = 0x0800;
  21  
  22      const COMPUTE = 1;
  23      const SEND = 2;
  24  
  25      private const CHUNKED_READ_BLOCK_SIZE = 1048576;
  26  
  27      /**
  28       * @var string
  29       */
  30      public $name;
  31  
  32      /**
  33       * @var FileOptions
  34       */
  35      public $opt;
  36  
  37      /**
  38       * @var Bigint
  39       */
  40      public $len;
  41      /**
  42       * @var Bigint
  43       */
  44      public $zlen;
  45  
  46      /** @var  int */
  47      public $crc;
  48  
  49      /**
  50       * @var Bigint
  51       */
  52      public $hlen;
  53  
  54      /**
  55       * @var Bigint
  56       */
  57      public $ofs;
  58  
  59      /**
  60       * @var int
  61       */
  62      public $bits;
  63  
  64      /**
  65       * @var Version
  66       */
  67      public $version;
  68  
  69      /**
  70       * @var ZipStream
  71       */
  72      public $zip;
  73  
  74      /**
  75       * @var resource
  76       */
  77      private $deflate;
  78      /**
  79       * @var resource
  80       */
  81      private $hash;
  82  
  83      /**
  84       * @var Method
  85       */
  86      private $method;
  87  
  88      /**
  89       * @var Bigint
  90       */
  91      private $totalLength;
  92  
  93      public function __construct(ZipStream $zip, string $name, ?FileOptions $opt = null)
  94      {
  95          $this->zip = $zip;
  96  
  97          $this->name = $name;
  98          $this->opt = $opt ?: new FileOptions();
  99          $this->method = $this->opt->getMethod();
 100          $this->version = Version::STORE();
 101          $this->ofs = new Bigint();
 102      }
 103  
 104      public function processPath(string $path): void
 105      {
 106          if (!is_readable($path)) {
 107              if (!file_exists($path)) {
 108                  throw new FileNotFoundException($path);
 109              }
 110              throw new FileNotReadableException($path);
 111          }
 112          if ($this->zip->isLargeFile($path) === false) {
 113              $data = file_get_contents($path);
 114              $this->processData($data);
 115          } else {
 116              $this->method = $this->zip->opt->getLargeFileMethod();
 117  
 118              $stream = new DeflateStream(fopen($path, 'rb'));
 119              $this->processStream($stream);
 120              $stream->close();
 121          }
 122      }
 123  
 124      public function processData(string $data): void
 125      {
 126          $this->len = new Bigint(strlen($data));
 127          $this->crc = crc32($data);
 128  
 129          // compress data if needed
 130          if ($this->method->equals(Method::DEFLATE())) {
 131              $data = gzdeflate($data);
 132          }
 133  
 134          $this->zlen = new Bigint(strlen($data));
 135          $this->addFileHeader();
 136          $this->zip->send($data);
 137          $this->addFileFooter();
 138      }
 139  
 140      /**
 141       * Create and send zip header for this file.
 142       *
 143       * @return void
 144       * @throws \ZipStream\Exception\EncodingException
 145       */
 146      public function addFileHeader(): void
 147      {
 148          $name = static::filterFilename($this->name);
 149  
 150          // calculate name length
 151          $nameLength = strlen($name);
 152  
 153          // create dos timestamp
 154          $time = static::dosTime($this->opt->getTime()->getTimestamp());
 155  
 156          $comment = $this->opt->getComment();
 157  
 158          if (!mb_check_encoding($name, 'ASCII') ||
 159              !mb_check_encoding($comment, 'ASCII')) {
 160              // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
 161              // the filename and comment fields for this file
 162              // MUST be encoded using UTF-8. (see APPENDIX D)
 163              if (!mb_check_encoding($name, 'UTF-8') ||
 164                  !mb_check_encoding($comment, 'UTF-8')) {
 165                  throw new EncodingException(
 166                      'File name and comment should use UTF-8 ' .
 167                      'if one of them does not fit into ASCII range.'
 168                  );
 169              }
 170              $this->bits |= self::BIT_EFS_UTF8;
 171          }
 172  
 173          if ($this->method->equals(Method::DEFLATE())) {
 174              $this->version = Version::DEFLATE();
 175          }
 176  
 177          $force = (boolean)($this->bits & self::BIT_ZERO_HEADER) &&
 178              $this->zip->opt->isEnableZip64();
 179  
 180          $footer = $this->buildZip64ExtraBlock($force);
 181  
 182          // If this file will start over 4GB limit in ZIP file,
 183          // CDR record will have to use Zip64 extension to describe offset
 184          // to keep consistency we use the same value here
 185          if ($this->zip->ofs->isOver32()) {
 186              $this->version = Version::ZIP64();
 187          }
 188  
 189          $fields = [
 190              ['V', ZipStream::FILE_HEADER_SIGNATURE],
 191              ['v', $this->version->getValue()],      // Version needed to Extract
 192              ['v', $this->bits],                     // General purpose bit flags - data descriptor flag set
 193              ['v', $this->method->getValue()],       // Compression method
 194              ['V', $time],                           // Timestamp (DOS Format)
 195              ['V', $this->crc],                      // CRC32 of data (0 -> moved to data descriptor footer)
 196              ['V', $this->zlen->getLowFF($force)],   // Length of compressed data (forced to 0xFFFFFFFF for zero header)
 197              ['V', $this->len->getLowFF($force)],    // Length of original data (forced to 0xFFFFFFFF for zero header)
 198              ['v', $nameLength],                     // Length of filename
 199              ['v', strlen($footer)],                 // Extra data (see above)
 200          ];
 201  
 202          // pack fields and calculate "total" length
 203          $header = ZipStream::packFields($fields);
 204  
 205          // print header and filename
 206          $data = $header . $name . $footer;
 207          $this->zip->send($data);
 208  
 209          // save header length
 210          $this->hlen = Bigint::init(strlen($data));
 211      }
 212  
 213      /**
 214       * Strip characters that are not legal in Windows filenames
 215       * to prevent compatibility issues
 216       *
 217       * @param string $filename Unprocessed filename
 218       * @return string
 219       */
 220      public static function filterFilename(string $filename): string
 221      {
 222          // strip leading slashes from file name
 223          // (fixes bug in windows archive viewer)
 224          $filename = preg_replace('/^\\/+/', '', $filename);
 225  
 226          return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename);
 227      }
 228  
 229      /**
 230       * Convert a UNIX timestamp to a DOS timestamp.
 231       *
 232       * @param int $when
 233       * @return int DOS Timestamp
 234       */
 235      final protected static function dosTime(int $when): int
 236      {
 237          // get date array for timestamp
 238          $d = getdate($when);
 239  
 240          // set lower-bound on dates
 241          if ($d['year'] < 1980) {
 242              $d = array(
 243                  'year' => 1980,
 244                  'mon' => 1,
 245                  'mday' => 1,
 246                  'hours' => 0,
 247                  'minutes' => 0,
 248                  'seconds' => 0
 249              );
 250          }
 251  
 252          // remove extra years from 1980
 253          $d['year'] -= 1980;
 254  
 255          // return date string
 256          return
 257              ($d['year'] << 25) |
 258              ($d['mon'] << 21) |
 259              ($d['mday'] << 16) |
 260              ($d['hours'] << 11) |
 261              ($d['minutes'] << 5) |
 262              ($d['seconds'] >> 1);
 263      }
 264  
 265      protected function buildZip64ExtraBlock(bool $force = false): string
 266      {
 267  
 268          $fields = [];
 269          if ($this->len->isOver32($force)) {
 270              $fields[] = ['P', $this->len];          // Length of original data
 271          }
 272  
 273          if ($this->len->isOver32($force)) {
 274              $fields[] = ['P', $this->zlen];         // Length of compressed data
 275          }
 276  
 277          if ($this->ofs->isOver32()) {
 278              $fields[] = ['P', $this->ofs];          // Offset of local header record
 279          }
 280  
 281          if (!empty($fields)) {
 282              if (!$this->zip->opt->isEnableZip64()) {
 283                  throw new OverflowException();
 284              }
 285  
 286              array_unshift(
 287                  $fields,
 288                  ['v', 0x0001],                      // 64 bit extension
 289                  ['v', count($fields) * 8]             // Length of data block
 290              );
 291              $this->version = Version::ZIP64();
 292          }
 293  
 294          return ZipStream::packFields($fields);
 295      }
 296  
 297      /**
 298       * Create and send data descriptor footer for this file.
 299       *
 300       * @return void
 301       */
 302  
 303      public function addFileFooter(): void
 304      {
 305  
 306          if ($this->bits & self::BIT_ZERO_HEADER) {
 307              // compressed and uncompressed size
 308              $sizeFormat = 'V';
 309              if ($this->zip->opt->isEnableZip64()) {
 310                  $sizeFormat = 'P';
 311              }
 312              $fields = [
 313                  ['V', ZipStream::DATA_DESCRIPTOR_SIGNATURE],
 314                  ['V', $this->crc],              // CRC32
 315                  [$sizeFormat, $this->zlen],     // Length of compressed data
 316                  [$sizeFormat, $this->len],      // Length of original data
 317              ];
 318  
 319              $footer = ZipStream::packFields($fields);
 320              $this->zip->send($footer);
 321          } else {
 322              $footer = '';
 323          }
 324          $this->totalLength = $this->hlen->add($this->zlen)->add(Bigint::init(strlen($footer)));
 325          $this->zip->addToCdr($this);
 326      }
 327  
 328      public function processStream(StreamInterface $stream): void
 329      {
 330          $this->zlen = new Bigint();
 331          $this->len = new Bigint();
 332  
 333          if ($this->zip->opt->isZeroHeader()) {
 334              $this->processStreamWithZeroHeader($stream);
 335          } else {
 336              $this->processStreamWithComputedHeader($stream);
 337          }
 338      }
 339  
 340      protected function processStreamWithZeroHeader(StreamInterface $stream): void
 341      {
 342          $this->bits |= self::BIT_ZERO_HEADER;
 343          $this->addFileHeader();
 344          $this->readStream($stream, self::COMPUTE | self::SEND);
 345          $this->addFileFooter();
 346      }
 347  
 348      protected function readStream(StreamInterface $stream, ?int $options = null): void
 349      {
 350          $this->deflateInit();
 351          $total = 0;
 352          $size = $this->opt->getSize();
 353          while (!$stream->eof() && ($size === 0 || $total < $size)) {
 354              $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
 355              $total += strlen($data);
 356              if ($size > 0 && $total > $size) {
 357                  $data = substr($data, 0 , strlen($data)-($total - $size));
 358              }
 359              $this->deflateData($stream, $data, $options);
 360              if ($options & self::SEND) {
 361                  $this->zip->send($data);
 362              }
 363          }
 364          $this->deflateFinish($options);
 365      }
 366  
 367      protected function deflateInit(): void
 368      {
 369          $this->hash = hash_init(self::HASH_ALGORITHM);
 370          if ($this->method->equals(Method::DEFLATE())) {
 371              $this->deflate = deflate_init(
 372                  ZLIB_ENCODING_RAW,
 373                  ['level' => $this->opt->getDeflateLevel()]
 374              );
 375          }
 376      }
 377  
 378      protected function deflateData(StreamInterface $stream, string &$data, ?int $options = null): void
 379      {
 380          if ($options & self::COMPUTE) {
 381              $this->len = $this->len->add(Bigint::init(strlen($data)));
 382              hash_update($this->hash, $data);
 383          }
 384          if ($this->deflate) {
 385              $data = deflate_add(
 386                  $this->deflate,
 387                  $data,
 388                  $stream->eof()
 389                      ? ZLIB_FINISH
 390                      : ZLIB_NO_FLUSH
 391              );
 392          }
 393          if ($options & self::COMPUTE) {
 394              $this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
 395          }
 396      }
 397  
 398      protected function deflateFinish(?int $options = null): void
 399      {
 400          if ($options & self::COMPUTE) {
 401              $this->crc = hexdec(hash_final($this->hash));
 402          }
 403      }
 404  
 405      protected function processStreamWithComputedHeader(StreamInterface $stream): void
 406      {
 407          $this->readStream($stream, self::COMPUTE);
 408          $stream->rewind();
 409  
 410          // incremental compression with deflate_add
 411          // makes this second read unnecessary
 412          // but it is only available from PHP 7.0
 413          if (!$this->deflate && $stream instanceof DeflateStream && $this->method->equals(Method::DEFLATE())) {
 414              $stream->addDeflateFilter($this->opt);
 415              $this->zlen = new Bigint();
 416              while (!$stream->eof()) {
 417                  $data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
 418                  $this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
 419              }
 420              $stream->rewind();
 421          }
 422  
 423          $this->addFileHeader();
 424          $this->readStream($stream, self::SEND);
 425          $this->addFileFooter();
 426      }
 427  
 428      /**
 429       * Send CDR record for specified file.
 430       *
 431       * @return string
 432       */
 433      public function getCdrFile(): string
 434      {
 435          $name = static::filterFilename($this->name);
 436  
 437          // get attributes
 438          $comment = $this->opt->getComment();
 439  
 440          // get dos timestamp
 441          $time = static::dosTime($this->opt->getTime()->getTimestamp());
 442  
 443          $footer = $this->buildZip64ExtraBlock();
 444  
 445          $fields = [
 446              ['V', ZipStream::CDR_FILE_SIGNATURE],   // Central file header signature
 447              ['v', ZipStream::ZIP_VERSION_MADE_BY],  // Made by version
 448              ['v', $this->version->getValue()],      // Extract by version
 449              ['v', $this->bits],                     // General purpose bit flags - data descriptor flag set
 450              ['v', $this->method->getValue()],       // Compression method
 451              ['V', $time],                           // Timestamp (DOS Format)
 452              ['V', $this->crc],                      // CRC32
 453              ['V', $this->zlen->getLowFF()],         // Compressed Data Length
 454              ['V', $this->len->getLowFF()],          // Original Data Length
 455              ['v', strlen($name)],                   // Length of filename
 456              ['v', strlen($footer)],                 // Extra data len (see above)
 457              ['v', strlen($comment)],                // Length of comment
 458              ['v', 0],                               // Disk number
 459              ['v', 0],                               // Internal File Attributes
 460              ['V', 32],                              // External File Attributes
 461              ['V', $this->ofs->getLowFF()]           // Relative offset of local header
 462          ];
 463  
 464          // pack fields, then append name and comment
 465          $header = ZipStream::packFields($fields);
 466  
 467          return $header . $name . $footer . $comment;
 468      }
 469  
 470      /**
 471       * @return Bigint
 472       */
 473      public function getTotalLength(): Bigint
 474      {
 475          return $this->totalLength;
 476      }
 477  }