File.php 14 KB

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