Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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

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