ZipStream.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871
  1. <?php
  2. declare(strict_types=1);
  3. namespace ZipStream;
  4. use Closure;
  5. use DateTimeImmutable;
  6. use DateTimeInterface;
  7. use GuzzleHttp\Psr7\StreamWrapper;
  8. use Psr\Http\Message\StreamInterface;
  9. use RuntimeException;
  10. use ZipStream\Exception\FileNotFoundException;
  11. use ZipStream\Exception\FileNotReadableException;
  12. use ZipStream\Exception\OverflowException;
  13. use ZipStream\Exception\ResourceActionException;
  14. /**
  15. * Streamed, dynamically generated zip archives.
  16. *
  17. * ## Usage
  18. *
  19. * Streaming zip archives is a simple, three-step process:
  20. *
  21. * 1. Create the zip stream:
  22. *
  23. * ```php
  24. * $zip = new ZipStream(outputName: 'example.zip');
  25. * ```
  26. *
  27. * 2. Add one or more files to the archive:
  28. *
  29. * ```php
  30. * // add first file
  31. * $zip->addFile(fileName: 'world.txt', data: 'Hello World');
  32. *
  33. * // add second file
  34. * $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon');
  35. * ```
  36. *
  37. * 3. Finish the zip stream:
  38. *
  39. * ```php
  40. * $zip->finish();
  41. * ```
  42. *
  43. * You can also add an archive comment, add comments to individual files,
  44. * and adjust the timestamp of files. See the API documentation for each
  45. * method below for additional information.
  46. *
  47. * ## Example
  48. *
  49. * ```php
  50. * // create a new zip stream object
  51. * $zip = new ZipStream(outputName: 'some_files.zip');
  52. *
  53. * // list of local files
  54. * $files = array('foo.txt', 'bar.jpg');
  55. *
  56. * // read and add each file to the archive
  57. * foreach ($files as $path)
  58. * $zip->addFileFromPath(fileName: $path, $path);
  59. *
  60. * // write archive footer to stream
  61. * $zip->finish();
  62. * ```
  63. */
  64. class ZipStream
  65. {
  66. /**
  67. * This number corresponds to the ZIP version/OS used (2 bytes)
  68. * From: https://www.iana.org/assignments/media-types/application/zip
  69. * The upper byte (leftmost one) indicates the host system (OS) for the
  70. * file. Software can use this information to determine
  71. * the line record format for text files etc. The current
  72. * mappings are:
  73. *
  74. * 0 - MS-DOS and OS/2 (F.A.T. file systems)
  75. * 1 - Amiga 2 - VAX/VMS
  76. * 3 - *nix 4 - VM/CMS
  77. * 5 - Atari ST 6 - OS/2 H.P.F.S.
  78. * 7 - Macintosh 8 - Z-System
  79. * 9 - CP/M 10 thru 255 - unused
  80. *
  81. * The lower byte (rightmost one) indicates the version number of the
  82. * software used to encode the file. The value/10
  83. * indicates the major version number, and the value
  84. * mod 10 is the minor version number.
  85. * Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
  86. * to prevent file permissions issues upon extract (see #84)
  87. * 0x603 is 00000110 00000011 in binary, so 6 and 3
  88. *
  89. * @internal
  90. */
  91. public const ZIP_VERSION_MADE_BY = 0x603;
  92. private bool $ready = true;
  93. private int $offset = 0;
  94. /**
  95. * @var string[]
  96. */
  97. private array $centralDirectoryRecords = [];
  98. /**
  99. * @var resource
  100. */
  101. private $outputStream;
  102. private readonly Closure $httpHeaderCallback;
  103. /**
  104. * @var File[]
  105. */
  106. private array $recordedSimulation = [];
  107. /**
  108. * Create a new ZipStream object.
  109. *
  110. * ##### Examples
  111. *
  112. * ```php
  113. * // create a new zip file named 'foo.zip'
  114. * $zip = new ZipStream(outputName: 'foo.zip');
  115. *
  116. * // create a new zip file named 'bar.zip' with a comment
  117. * $zip = new ZipStream(
  118. * outputName: 'bar.zip',
  119. * comment: 'this is a comment for the zip file.',
  120. * );
  121. * ```
  122. *
  123. * @param OperationMode $operationMode
  124. * The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes.
  125. * For details see the `OperationMode` documentation.
  126. *
  127. * Default to `NORMAL`.
  128. *
  129. * @param string $comment
  130. * Archive Level Comment
  131. *
  132. * @param StreamInterface|resource|null $outputStream
  133. * Override the output of the archive to a different target.
  134. *
  135. * By default the archive is sent to `STDOUT`.
  136. *
  137. * @param CompressionMethod $defaultCompressionMethod
  138. * How to handle file compression. Legal values are
  139. * `CompressionMethod::DEFLATE` (the default), or
  140. * `CompressionMethod::STORE`. `STORE` sends the file raw and is
  141. * significantly faster, while `DEFLATE` compresses the file and
  142. * is much, much slower.
  143. *
  144. * @param int $defaultDeflateLevel
  145. * Default deflation level. Only relevant if `compressionMethod`
  146. * is `DEFLATE`.
  147. *
  148. * See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters)
  149. *
  150. * @param bool $enableZip64
  151. * Enable Zip64 extension, supporting very large
  152. * archives (any size > 4 GB or file count > 64k)
  153. *
  154. * @param bool $defaultEnableZeroHeader
  155. * Enable streaming files with single read.
  156. *
  157. * When the zero header is set, the file is streamed into the output
  158. * and the size & checksum are added at the end of the file. This is the
  159. * fastest method and uses the least memory. Unfortunately not all
  160. * ZIP clients fully support this and can lead to clients reporting
  161. * the generated ZIP files as corrupted in combination with other
  162. * circumstances. (Zip64 enabled, using UTF8 in comments / names etc.)
  163. *
  164. * When the zero header is not set, the length & checksum need to be
  165. * defined before the file is actually added. To prevent loading all
  166. * the data into memory, the data has to be read twice. If the data
  167. * which is added is not seekable, this call will fail.
  168. *
  169. * @param bool $sendHttpHeaders
  170. * Boolean indicating whether or not to send
  171. * the HTTP headers for this file.
  172. *
  173. * @param ?Closure $httpHeaderCallback
  174. * The method called to send HTTP headers
  175. *
  176. * @param string|null $outputName
  177. * The name of the created archive.
  178. *
  179. * Only relevant if `$sendHttpHeaders = true`.
  180. *
  181. * @param string $contentDisposition
  182. * HTTP Content-Disposition
  183. *
  184. * Only relevant if `sendHttpHeaders = true`.
  185. *
  186. * @param string $contentType
  187. * HTTP Content Type
  188. *
  189. * Only relevant if `sendHttpHeaders = true`.
  190. *
  191. * @param bool $flushOutput
  192. * Enable flush after every write to output stream.
  193. *
  194. * @return self
  195. */
  196. public function __construct(
  197. private OperationMode $operationMode = OperationMode::NORMAL,
  198. private readonly string $comment = '',
  199. $outputStream = null,
  200. private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE,
  201. private readonly int $defaultDeflateLevel = 6,
  202. private readonly bool $enableZip64 = true,
  203. private readonly bool $defaultEnableZeroHeader = true,
  204. private bool $sendHttpHeaders = true,
  205. ?Closure $httpHeaderCallback = null,
  206. private readonly ?string $outputName = null,
  207. private readonly string $contentDisposition = 'attachment',
  208. private readonly string $contentType = 'application/x-zip',
  209. private bool $flushOutput = false,
  210. ) {
  211. $this->outputStream = self::normalizeStream($outputStream);
  212. $this->httpHeaderCallback = $httpHeaderCallback ?? header(...);
  213. }
  214. /**
  215. * Add a file to the archive.
  216. *
  217. * ##### File Options
  218. *
  219. * See {@see addFileFromPsr7Stream()}
  220. *
  221. * ##### Examples
  222. *
  223. * ```php
  224. * // add a file named 'world.txt'
  225. * $zip->addFile(fileName: 'world.txt', data: 'Hello World!');
  226. *
  227. * // add a file named 'bar.jpg' with a comment and a last-modified
  228. * // time of two hours ago
  229. * $zip->addFile(
  230. * fileName: 'bar.jpg',
  231. * data: $data,
  232. * comment: 'this is a comment about bar.jpg',
  233. * lastModificationDateTime: new DateTime('2 hours ago'),
  234. * );
  235. * ```
  236. *
  237. * @param string $data
  238. *
  239. * contents of file
  240. */
  241. public function addFile(
  242. string $fileName,
  243. string $data,
  244. string $comment = '',
  245. ?CompressionMethod $compressionMethod = null,
  246. ?int $deflateLevel = null,
  247. ?DateTimeInterface $lastModificationDateTime = null,
  248. ?int $maxSize = null,
  249. ?int $exactSize = null,
  250. ?bool $enableZeroHeader = null,
  251. ): void {
  252. $this->addFileFromCallback(
  253. fileName: $fileName,
  254. callback: fn() => $data,
  255. comment: $comment,
  256. compressionMethod: $compressionMethod,
  257. deflateLevel: $deflateLevel,
  258. lastModificationDateTime: $lastModificationDateTime,
  259. maxSize: $maxSize,
  260. exactSize: $exactSize,
  261. enableZeroHeader: $enableZeroHeader,
  262. );
  263. }
  264. /**
  265. * Add a file at path to the archive.
  266. *
  267. * ##### File Options
  268. *
  269. * See {@see addFileFromPsr7Stream()}
  270. *
  271. * ###### Examples
  272. *
  273. * ```php
  274. * // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
  275. * $zip->addFileFromPath(
  276. * fileName: 'foo.txt',
  277. * path: '/tmp/foo.txt',
  278. * );
  279. *
  280. * // add a file named 'bigfile.rar' from the local file
  281. * // '/usr/share/bigfile.rar' with a comment and a last-modified
  282. * // time of two hours ago
  283. * $zip->addFileFromPath(
  284. * fileName: 'bigfile.rar',
  285. * path: '/usr/share/bigfile.rar',
  286. * comment: 'this is a comment about bigfile.rar',
  287. * lastModificationDateTime: new DateTime('2 hours ago'),
  288. * );
  289. * ```
  290. *
  291. * @throws \ZipStream\Exception\FileNotFoundException
  292. * @throws \ZipStream\Exception\FileNotReadableException
  293. */
  294. public function addFileFromPath(
  295. /**
  296. * name of file in archive (including directory path).
  297. */
  298. string $fileName,
  299. /**
  300. * path to file on disk (note: paths should be encoded using
  301. * UNIX-style forward slashes -- e.g '/path/to/some/file').
  302. */
  303. string $path,
  304. string $comment = '',
  305. ?CompressionMethod $compressionMethod = null,
  306. ?int $deflateLevel = null,
  307. ?DateTimeInterface $lastModificationDateTime = null,
  308. ?int $maxSize = null,
  309. ?int $exactSize = null,
  310. ?bool $enableZeroHeader = null,
  311. ): void {
  312. if (!is_readable($path)) {
  313. if (!file_exists($path)) {
  314. throw new FileNotFoundException($path);
  315. }
  316. throw new FileNotReadableException($path);
  317. }
  318. $fileTime = filemtime($path);
  319. if ($fileTime !== false) {
  320. $lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);
  321. }
  322. $this->addFileFromCallback(
  323. fileName: $fileName,
  324. callback: function () use ($path) {
  325. $stream = fopen($path, 'rb');
  326. if (!$stream) {
  327. // @codeCoverageIgnoreStart
  328. throw new ResourceActionException('fopen');
  329. // @codeCoverageIgnoreEnd
  330. }
  331. return $stream;
  332. },
  333. comment: $comment,
  334. compressionMethod: $compressionMethod,
  335. deflateLevel: $deflateLevel,
  336. lastModificationDateTime: $lastModificationDateTime,
  337. maxSize: $maxSize,
  338. exactSize: $exactSize,
  339. enableZeroHeader: $enableZeroHeader,
  340. );
  341. }
  342. /**
  343. * Add an open stream (resource) to the archive.
  344. *
  345. * ##### File Options
  346. *
  347. * See {@see addFileFromPsr7Stream()}
  348. *
  349. * ##### Examples
  350. *
  351. * ```php
  352. * // create a temporary file stream and write text to it
  353. * $filePointer = tmpfile();
  354. * fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
  355. *
  356. * // add a file named 'streamfile.txt' from the content of the stream
  357. * $archive->addFileFromStream(
  358. * fileName: 'streamfile.txt',
  359. * stream: $filePointer,
  360. * );
  361. * ```
  362. *
  363. * @param resource $stream contents of file as a stream resource
  364. */
  365. public function addFileFromStream(
  366. string $fileName,
  367. $stream,
  368. string $comment = '',
  369. ?CompressionMethod $compressionMethod = null,
  370. ?int $deflateLevel = null,
  371. ?DateTimeInterface $lastModificationDateTime = null,
  372. ?int $maxSize = null,
  373. ?int $exactSize = null,
  374. ?bool $enableZeroHeader = null,
  375. ): void {
  376. $this->addFileFromCallback(
  377. fileName: $fileName,
  378. callback: fn() => $stream,
  379. comment: $comment,
  380. compressionMethod: $compressionMethod,
  381. deflateLevel: $deflateLevel,
  382. lastModificationDateTime: $lastModificationDateTime,
  383. maxSize: $maxSize,
  384. exactSize: $exactSize,
  385. enableZeroHeader: $enableZeroHeader,
  386. );
  387. }
  388. /**
  389. * Add an open stream to the archive.
  390. *
  391. * ##### Examples
  392. *
  393. * ```php
  394. * $stream = $response->getBody();
  395. * // add a file named 'streamfile.txt' from the content of the stream
  396. * $archive->addFileFromPsr7Stream(
  397. * fileName: 'streamfile.txt',
  398. * stream: $stream,
  399. * );
  400. * ```
  401. *
  402. * @param string $fileName
  403. * path of file in archive (including directory)
  404. *
  405. * @param StreamInterface $stream
  406. * contents of file as a stream resource
  407. *
  408. * @param string $comment
  409. * ZIP comment for this file
  410. *
  411. * @param ?CompressionMethod $compressionMethod
  412. * Override `defaultCompressionMethod`
  413. *
  414. * See {@see __construct()}
  415. *
  416. * @param ?int $deflateLevel
  417. * Override `defaultDeflateLevel`
  418. *
  419. * See {@see __construct()}
  420. *
  421. * @param ?DateTimeInterface $lastModificationDateTime
  422. * Set last modification time of file.
  423. *
  424. * Default: `now`
  425. *
  426. * @param ?int $maxSize
  427. * Only read `maxSize` bytes from file.
  428. *
  429. * The file is considered done when either reaching `EOF`
  430. * or the `maxSize`.
  431. *
  432. * @param ?int $exactSize
  433. * Read exactly `exactSize` bytes from file.
  434. * If `EOF` is reached before reading `exactSize` bytes, an error will be
  435. * thrown. The parameter allows for faster size calculations if the `stream`
  436. * does not support `fstat` size or is slow and otherwise known beforehand.
  437. *
  438. * @param ?bool $enableZeroHeader
  439. * Override `defaultEnableZeroHeader`
  440. *
  441. * See {@see __construct()}
  442. */
  443. public function addFileFromPsr7Stream(
  444. string $fileName,
  445. StreamInterface $stream,
  446. string $comment = '',
  447. ?CompressionMethod $compressionMethod = null,
  448. ?int $deflateLevel = null,
  449. ?DateTimeInterface $lastModificationDateTime = null,
  450. ?int $maxSize = null,
  451. ?int $exactSize = null,
  452. ?bool $enableZeroHeader = null,
  453. ): void {
  454. $this->addFileFromCallback(
  455. fileName: $fileName,
  456. callback: fn() => $stream,
  457. comment: $comment,
  458. compressionMethod: $compressionMethod,
  459. deflateLevel: $deflateLevel,
  460. lastModificationDateTime: $lastModificationDateTime,
  461. maxSize: $maxSize,
  462. exactSize: $exactSize,
  463. enableZeroHeader: $enableZeroHeader,
  464. );
  465. }
  466. /**
  467. * Add a file based on a callback.
  468. *
  469. * This is useful when you want to simulate a lot of files without keeping
  470. * all of the file handles open at the same time.
  471. *
  472. * ##### Examples
  473. *
  474. * ```php
  475. * foreach($files as $name => $size) {
  476. * $archive->addFileFromCallback(
  477. * fileName: 'streamfile.txt',
  478. * exactSize: $size,
  479. * callback: function() use($name): Psr\Http\Message\StreamInterface {
  480. * $response = download($name);
  481. * return $response->getBody();
  482. * }
  483. * );
  484. * }
  485. * ```
  486. *
  487. * @param string $fileName
  488. * path of file in archive (including directory)
  489. *
  490. * @param Closure $callback
  491. * @psalm-param Closure(): (resource|StreamInterface|string) $callback
  492. * A callback to get the file contents in the shape of a PHP stream,
  493. * a Psr StreamInterface implementation, or a string.
  494. *
  495. * @param string $comment
  496. * ZIP comment for this file
  497. *
  498. * @param ?CompressionMethod $compressionMethod
  499. * Override `defaultCompressionMethod`
  500. *
  501. * See {@see __construct()}
  502. *
  503. * @param ?int $deflateLevel
  504. * Override `defaultDeflateLevel`
  505. *
  506. * See {@see __construct()}
  507. *
  508. * @param ?DateTimeInterface $lastModificationDateTime
  509. * Set last modification time of file.
  510. *
  511. * Default: `now`
  512. *
  513. * @param ?int $maxSize
  514. * Only read `maxSize` bytes from file.
  515. *
  516. * The file is considered done when either reaching `EOF`
  517. * or the `maxSize`.
  518. *
  519. * @param ?int $exactSize
  520. * Read exactly `exactSize` bytes from file.
  521. * If `EOF` is reached before reading `exactSize` bytes, an error will be
  522. * thrown. The parameter allows for faster size calculations if the `stream`
  523. * does not support `fstat` size or is slow and otherwise known beforehand.
  524. *
  525. * @param ?bool $enableZeroHeader
  526. * Override `defaultEnableZeroHeader`
  527. *
  528. * See {@see __construct()}
  529. */
  530. public function addFileFromCallback(
  531. string $fileName,
  532. Closure $callback,
  533. string $comment = '',
  534. ?CompressionMethod $compressionMethod = null,
  535. ?int $deflateLevel = null,
  536. ?DateTimeInterface $lastModificationDateTime = null,
  537. ?int $maxSize = null,
  538. ?int $exactSize = null,
  539. ?bool $enableZeroHeader = null,
  540. ): void {
  541. $file = new File(
  542. dataCallback: function () use ($callback, $maxSize) {
  543. $data = $callback();
  544. if (is_resource($data)) {
  545. return $data;
  546. }
  547. if ($data instanceof StreamInterface) {
  548. return StreamWrapper::getResource($data);
  549. }
  550. $stream = fopen('php://memory', 'rw+');
  551. if ($stream === false) {
  552. // @codeCoverageIgnoreStart
  553. throw new ResourceActionException('fopen');
  554. // @codeCoverageIgnoreEnd
  555. }
  556. if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {
  557. // @codeCoverageIgnoreStart
  558. throw new ResourceActionException('fwrite', $stream);
  559. // @codeCoverageIgnoreEnd
  560. } elseif (fwrite($stream, $data) === false) {
  561. // @codeCoverageIgnoreStart
  562. throw new ResourceActionException('fwrite', $stream);
  563. // @codeCoverageIgnoreEnd
  564. }
  565. if (rewind($stream) === false) {
  566. // @codeCoverageIgnoreStart
  567. throw new ResourceActionException('rewind', $stream);
  568. // @codeCoverageIgnoreEnd
  569. }
  570. return $stream;
  571. },
  572. send: $this->send(...),
  573. recordSentBytes: $this->recordSentBytes(...),
  574. operationMode: $this->operationMode,
  575. fileName: $fileName,
  576. startOffset: $this->offset,
  577. compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,
  578. comment: $comment,
  579. deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,
  580. lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),
  581. maxSize: $maxSize,
  582. exactSize: $exactSize,
  583. enableZip64: $this->enableZip64,
  584. enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,
  585. );
  586. if ($this->operationMode !== OperationMode::NORMAL) {
  587. $this->recordedSimulation[] = $file;
  588. }
  589. $this->centralDirectoryRecords[] = $file->process();
  590. }
  591. /**
  592. * Add a directory to the archive.
  593. *
  594. * ##### File Options
  595. *
  596. * See {@see addFileFromPsr7Stream()}
  597. *
  598. * ##### Examples
  599. *
  600. * ```php
  601. * // add a directory named 'world/'
  602. * $zip->addDirectory(fileName: 'world/');
  603. * ```
  604. */
  605. public function addDirectory(
  606. string $fileName,
  607. string $comment = '',
  608. ?DateTimeInterface $lastModificationDateTime = null,
  609. ): void {
  610. if (!str_ends_with($fileName, '/')) {
  611. $fileName .= '/';
  612. }
  613. $this->addFile(
  614. fileName: $fileName,
  615. data: '',
  616. comment: $comment,
  617. compressionMethod: CompressionMethod::STORE,
  618. deflateLevel: null,
  619. lastModificationDateTime: $lastModificationDateTime,
  620. maxSize: 0,
  621. exactSize: 0,
  622. enableZeroHeader: false,
  623. );
  624. }
  625. /**
  626. * Executes a previously calculated simulation.
  627. *
  628. * ##### Example
  629. *
  630. * ```php
  631. * $zip = new ZipStream(
  632. * outputName: 'foo.zip',
  633. * operationMode: OperationMode::SIMULATE_STRICT,
  634. * );
  635. *
  636. * $zip->addFile('test.txt', 'Hello World');
  637. *
  638. * $size = $zip->finish();
  639. *
  640. * header('Content-Length: '. $size);
  641. *
  642. * $zip->executeSimulation();
  643. * ```
  644. */
  645. public function executeSimulation(): void
  646. {
  647. if ($this->operationMode !== OperationMode::NORMAL) {
  648. throw new RuntimeException('Zip simulation is not finished.');
  649. }
  650. foreach ($this->recordedSimulation as $file) {
  651. $this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();
  652. }
  653. $this->finish();
  654. }
  655. /**
  656. * Write zip footer to stream.
  657. *
  658. * The clase is left in an unusable state after `finish`.
  659. *
  660. * ##### Example
  661. *
  662. * ```php
  663. * // write footer to stream
  664. * $zip->finish();
  665. * ```
  666. */
  667. public function finish(): int
  668. {
  669. $centralDirectoryStartOffsetOnDisk = $this->offset;
  670. $sizeOfCentralDirectory = 0;
  671. // add trailing cdr file records
  672. foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) {
  673. $this->send($centralDirectoryRecord);
  674. $sizeOfCentralDirectory += strlen($centralDirectoryRecord);
  675. }
  676. // Add 64bit headers (if applicable)
  677. if (count($this->centralDirectoryRecords) >= 0xFFFF ||
  678. $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
  679. $sizeOfCentralDirectory > 0xFFFFFFFF) {
  680. if (!$this->enableZip64) {
  681. throw new OverflowException();
  682. }
  683. $this->send(Zip64\EndOfCentralDirectory::generate(
  684. versionMadeBy: self::ZIP_VERSION_MADE_BY,
  685. versionNeededToExtract: Version::ZIP64->value,
  686. numberOfThisDisk: 0,
  687. numberOfTheDiskWithCentralDirectoryStart: 0,
  688. numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),
  689. numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),
  690. sizeOfCentralDirectory: $sizeOfCentralDirectory,
  691. centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,
  692. extensibleDataSector: '',
  693. ));
  694. $this->send(Zip64\EndOfCentralDirectoryLocator::generate(
  695. numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,
  696. zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,
  697. totalNumberOfDisks: 1,
  698. ));
  699. }
  700. // add trailing cdr eof record
  701. $numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);
  702. $this->send(EndOfCentralDirectory::generate(
  703. numberOfThisDisk: 0x00,
  704. numberOfTheDiskWithCentralDirectoryStart: 0x00,
  705. numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,
  706. numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,
  707. sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),
  708. centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),
  709. zipFileComment: $this->comment,
  710. ));
  711. $size = $this->offset;
  712. // The End
  713. $this->clear();
  714. return $size;
  715. }
  716. /**
  717. * @param StreamInterface|resource|null $outputStream
  718. * @return resource
  719. */
  720. private static function normalizeStream($outputStream)
  721. {
  722. if ($outputStream instanceof StreamInterface) {
  723. return StreamWrapper::getResource($outputStream);
  724. }
  725. if (is_resource($outputStream)) {
  726. return $outputStream;
  727. }
  728. $resource = fopen('php://output', 'wb');
  729. if ($resource === false) {
  730. throw new RuntimeException('fopen of php://output failed');
  731. }
  732. return $resource;
  733. }
  734. /**
  735. * Record sent bytes
  736. */
  737. private function recordSentBytes(int $sentBytes): void
  738. {
  739. $this->offset += $sentBytes;
  740. }
  741. /**
  742. * Send string, sending HTTP headers if necessary.
  743. * Flush output after write if configure option is set.
  744. */
  745. private function send(string $data): void
  746. {
  747. if (!$this->ready) {
  748. throw new RuntimeException('Archive is already finished');
  749. }
  750. if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {
  751. $this->sendHttpHeaders();
  752. $this->sendHttpHeaders = false;
  753. }
  754. $this->recordSentBytes(strlen($data));
  755. if ($this->operationMode === OperationMode::NORMAL) {
  756. if (fwrite($this->outputStream, $data) === false) {
  757. throw new ResourceActionException('fwrite', $this->outputStream);
  758. }
  759. if ($this->flushOutput) {
  760. // flush output buffer if it is on and flushable
  761. $status = ob_get_status();
  762. if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
  763. ob_flush();
  764. }
  765. // Flush system buffers after flushing userspace output buffer
  766. flush();
  767. }
  768. }
  769. }
  770. /**
  771. * Send HTTP headers for this stream.
  772. */
  773. private function sendHttpHeaders(): void
  774. {
  775. // grab content disposition
  776. $disposition = $this->contentDisposition;
  777. if ($this->outputName !== null) {
  778. // Various different browsers dislike various characters here. Strip them all for safety.
  779. $safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));
  780. // Check if we need to UTF-8 encode the filename
  781. $urlencoded = rawurlencode($safeOutput);
  782. $disposition .= "; filename*=UTF-8''{$urlencoded}";
  783. }
  784. $headers = [
  785. 'Content-Type' => $this->contentType,
  786. 'Content-Disposition' => $disposition,
  787. 'Pragma' => 'public',
  788. 'Cache-Control' => 'public, must-revalidate',
  789. 'Content-Transfer-Encoding' => 'binary',
  790. ];
  791. foreach ($headers as $key => $val) {
  792. ($this->httpHeaderCallback)("$key: $val");
  793. }
  794. }
  795. /**
  796. * Clear all internal variables. Note that the stream object is not
  797. * usable after this.
  798. */
  799. private function clear(): void
  800. {
  801. $this->centralDirectoryRecords = [];
  802. $this->offset = 0;
  803. if ($this->operationMode === OperationMode::NORMAL) {
  804. $this->ready = false;
  805. $this->recordedSimulation = [];
  806. } else {
  807. $this->operationMode = OperationMode::NORMAL;
  808. }
  809. }
  810. }