Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

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