File.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. <?php
  2. declare(strict_types=1);
  3. namespace ZipStream;
  4. use Closure;
  5. use DateTimeInterface;
  6. use DeflateContext;
  7. use RuntimeException;
  8. use ZipStream\Exception\FileSizeIncorrectException;
  9. use ZipStream\Exception\OverflowException;
  10. use ZipStream\Exception\ResourceActionException;
  11. use ZipStream\Exception\SimulationFileUnknownException;
  12. use ZipStream\Exception\StreamNotReadableException;
  13. use ZipStream\Exception\StreamNotSeekableException;
  14. /**
  15. * @internal
  16. */
  17. class File
  18. {
  19. private const CHUNKED_READ_BLOCK_SIZE = 0x1000000;
  20. private Version $version;
  21. private int $compressedSize = 0;
  22. private int $uncompressedSize = 0;
  23. private int $crc = 0;
  24. private int $generalPurposeBitFlag = 0;
  25. private readonly string $fileName;
  26. /**
  27. * @var resource|null
  28. */
  29. private $stream;
  30. /**
  31. * @param Closure $dataCallback
  32. * @psalm-param Closure(): resource $dataCallback
  33. */
  34. public function __construct(
  35. string $fileName,
  36. private readonly Closure $dataCallback,
  37. private readonly OperationMode $operationMode,
  38. private readonly int $startOffset,
  39. private readonly CompressionMethod $compressionMethod,
  40. private readonly string $comment,
  41. private readonly DateTimeInterface $lastModificationDateTime,
  42. private readonly int $deflateLevel,
  43. private readonly ?int $maxSize,
  44. private readonly ?int $exactSize,
  45. private readonly bool $enableZip64,
  46. private readonly bool $enableZeroHeader,
  47. private readonly Closure $send,
  48. private readonly Closure $recordSentBytes,
  49. ) {
  50. $this->fileName = self::filterFilename($fileName);
  51. $this->checkEncoding();
  52. if ($this->enableZeroHeader) {
  53. $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
  54. }
  55. $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
  56. }
  57. public function cloneSimulationExecution(): self
  58. {
  59. return new self(
  60. $this->fileName,
  61. $this->dataCallback,
  62. OperationMode::NORMAL,
  63. $this->startOffset,
  64. $this->compressionMethod,
  65. $this->comment,
  66. $this->lastModificationDateTime,
  67. $this->deflateLevel,
  68. $this->maxSize,
  69. $this->exactSize,
  70. $this->enableZip64,
  71. $this->enableZeroHeader,
  72. $this->send,
  73. $this->recordSentBytes,
  74. );
  75. }
  76. public function process(): string
  77. {
  78. $forecastSize = $this->forecastSize();
  79. if ($this->enableZeroHeader) {
  80. // No calculation required
  81. } elseif ($this->isSimulation() && $forecastSize) {
  82. $this->uncompressedSize = $forecastSize;
  83. $this->compressedSize = $forecastSize;
  84. } else {
  85. $this->readStream(send: false);
  86. if (rewind($this->unpackStream()) === false) {
  87. throw new ResourceActionException('rewind', $this->unpackStream());
  88. }
  89. }
  90. $this->addFileHeader();
  91. $detectedSize = $forecastSize ?? $this->compressedSize;
  92. if (
  93. $this->isSimulation() &&
  94. $detectedSize > 0
  95. ) {
  96. ($this->recordSentBytes)($detectedSize);
  97. } else {
  98. $this->readStream(send: true);
  99. }
  100. $this->addFileFooter();
  101. return $this->getCdrFile();
  102. }
  103. /**
  104. * @return resource
  105. */
  106. private function unpackStream()
  107. {
  108. if ($this->stream) {
  109. return $this->stream;
  110. }
  111. if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
  112. throw new SimulationFileUnknownException();
  113. }
  114. $this->stream = ($this->dataCallback)();
  115. if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
  116. throw new StreamNotSeekableException();
  117. }
  118. if (!(
  119. str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
  120. || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
  121. || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
  122. || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
  123. || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
  124. )) {
  125. throw new StreamNotReadableException();
  126. }
  127. return $this->stream;
  128. }
  129. private function forecastSize(): ?int
  130. {
  131. if ($this->compressionMethod !== CompressionMethod::STORE) {
  132. return null;
  133. }
  134. if ($this->exactSize) {
  135. return $this->exactSize;
  136. }
  137. $fstat = fstat($this->unpackStream());
  138. if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
  139. return null;
  140. }
  141. if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
  142. return $this->maxSize;
  143. }
  144. return $fstat['size'];
  145. }
  146. /**
  147. * Create and send zip header for this file.
  148. */
  149. private function addFileHeader(): void
  150. {
  151. $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
  152. $footer = $this->buildZip64ExtraBlock($forceEnableZip64);
  153. $zip64Enabled = $footer !== '';
  154. if($zip64Enabled) {
  155. $this->version = Version::ZIP64;
  156. }
  157. if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
  158. // Put the tricky entry to
  159. // force Linux unzip to lookup EFS flag.
  160. $footer .= Zs\ExtendedInformationExtraField::generate();
  161. }
  162. $data = LocalFileHeader::generate(
  163. versionNeededToExtract: $this->version->value,
  164. generalPurposeBitFlag: $this->generalPurposeBitFlag,
  165. compressionMethod: $this->compressionMethod,
  166. lastModificationDateTime: $this->lastModificationDateTime,
  167. crc32UncompressedData: $this->crc,
  168. compressedSize: $zip64Enabled
  169. ? 0xFFFFFFFF
  170. : $this->compressedSize,
  171. uncompressedSize: $zip64Enabled
  172. ? 0xFFFFFFFF
  173. : $this->uncompressedSize,
  174. fileName: $this->fileName,
  175. extraField: $footer,
  176. );
  177. ($this->send)($data);
  178. }
  179. /**
  180. * Strip characters that are not legal in Windows filenames
  181. * to prevent compatibility issues
  182. */
  183. private static function filterFilename(
  184. /**
  185. * Unprocessed filename
  186. */
  187. string $fileName
  188. ): string {
  189. // strip leading slashes from file name
  190. // (fixes bug in windows archive viewer)
  191. $fileName = ltrim($fileName, '/');
  192. return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
  193. }
  194. private function checkEncoding(): void
  195. {
  196. // Sets Bit 11: Language encoding flag (EFS). If this bit is set,
  197. // the filename and comment fields for this file
  198. // MUST be encoded using UTF-8. (see APPENDIX D)
  199. if (mb_check_encoding($this->fileName, 'UTF-8') &&
  200. mb_check_encoding($this->comment, 'UTF-8')) {
  201. $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
  202. }
  203. }
  204. private function buildZip64ExtraBlock(bool $force = false): string
  205. {
  206. $outputZip64ExtraBlock = false;
  207. $originalSize = null;
  208. if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
  209. $outputZip64ExtraBlock = true;
  210. $originalSize = $this->uncompressedSize;
  211. }
  212. $compressedSize = null;
  213. if ($force || $this->compressedSize > 0xFFFFFFFF) {
  214. $outputZip64ExtraBlock = true;
  215. $compressedSize = $this->compressedSize;
  216. }
  217. // If this file will start over 4GB limit in ZIP file,
  218. // CDR record will have to use Zip64 extension to describe offset
  219. // to keep consistency we use the same value here
  220. $relativeHeaderOffset = null;
  221. if ($this->startOffset > 0xFFFFFFFF) {
  222. $outputZip64ExtraBlock = true;
  223. $relativeHeaderOffset = $this->startOffset;
  224. }
  225. if (!$outputZip64ExtraBlock) {
  226. return '';
  227. }
  228. if (!$this->enableZip64) {
  229. throw new OverflowException();
  230. }
  231. return Zip64\ExtendedInformationExtraField::generate(
  232. originalSize: $originalSize,
  233. compressedSize: $compressedSize,
  234. relativeHeaderOffset: $relativeHeaderOffset,
  235. diskStartNumber: null,
  236. );
  237. }
  238. private function addFileFooter(): void
  239. {
  240. if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
  241. throw new OverflowException();
  242. }
  243. if (!$this->enableZeroHeader) {
  244. return;
  245. }
  246. if ($this->version === Version::ZIP64) {
  247. $footer = Zip64\DataDescriptor::generate(
  248. crc32UncompressedData: $this->crc,
  249. compressedSize: $this->compressedSize,
  250. uncompressedSize: $this->uncompressedSize,
  251. );
  252. } else {
  253. $footer = DataDescriptor::generate(
  254. crc32UncompressedData: $this->crc,
  255. compressedSize: $this->compressedSize,
  256. uncompressedSize: $this->uncompressedSize,
  257. );
  258. }
  259. ($this->send)($footer);
  260. }
  261. private function readStream(bool $send): void
  262. {
  263. $this->compressedSize = 0;
  264. $this->uncompressedSize = 0;
  265. $hash = hash_init('crc32b');
  266. $deflate = $this->compressionInit();
  267. while (
  268. !feof($this->unpackStream()) &&
  269. ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
  270. ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
  271. ) {
  272. $readLength = min(
  273. ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
  274. ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
  275. self::CHUNKED_READ_BLOCK_SIZE
  276. );
  277. $data = fread($this->unpackStream(), $readLength);
  278. hash_update($hash, $data);
  279. $this->uncompressedSize += strlen($data);
  280. if ($deflate) {
  281. $data = deflate_add(
  282. $deflate,
  283. $data,
  284. feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
  285. );
  286. }
  287. $this->compressedSize += strlen($data);
  288. if ($send) {
  289. ($this->send)($data);
  290. }
  291. }
  292. if ($this->exactSize && $this->uncompressedSize !== $this->exactSize) {
  293. throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
  294. }
  295. $this->crc = hexdec(hash_final($hash));
  296. }
  297. private function compressionInit(): ?DeflateContext
  298. {
  299. switch($this->compressionMethod) {
  300. case CompressionMethod::STORE:
  301. // Noting to do
  302. return null;
  303. case CompressionMethod::DEFLATE:
  304. $deflateContext = deflate_init(
  305. ZLIB_ENCODING_RAW,
  306. ['level' => $this->deflateLevel]
  307. );
  308. if (!$deflateContext) {
  309. // @codeCoverageIgnoreStart
  310. throw new RuntimeException("Can't initialize deflate context.");
  311. // @codeCoverageIgnoreEnd
  312. }
  313. // False positive, resource is no longer returned from this function
  314. return $deflateContext;
  315. default:
  316. // @codeCoverageIgnoreStart
  317. throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
  318. // @codeCoverageIgnoreEnd
  319. }
  320. }
  321. private function getCdrFile(): string
  322. {
  323. $footer = $this->buildZip64ExtraBlock();
  324. return CentralDirectoryFileHeader::generate(
  325. versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
  326. versionNeededToExtract:$this->version->value,
  327. generalPurposeBitFlag: $this->generalPurposeBitFlag,
  328. compressionMethod: $this->compressionMethod,
  329. lastModificationDateTime: $this->lastModificationDateTime,
  330. crc32: $this->crc,
  331. compressedSize: $this->compressedSize > 0xFFFFFFFF
  332. ? 0xFFFFFFFF
  333. : $this->compressedSize,
  334. uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
  335. ? 0xFFFFFFFF
  336. : $this->uncompressedSize,
  337. fileName: $this->fileName,
  338. extraField: $footer,
  339. fileComment: $this->comment,
  340. diskNumberStart: 0,
  341. internalFileAttributes: 0,
  342. externalFileAttributes: 32,
  343. relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
  344. ? 0xFFFFFFFF
  345. : $this->startOffset,
  346. );
  347. }
  348. private function isSimulation(): bool
  349. {
  350. return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
  351. }
  352. }