1 <?php
2 
3 namespace splitbrain\PHPArchive;
4 
5 /**
6  * Class Zip
7  *
8  * Creates or extracts Zip archives
9  *
10  * for specs see http://www.pkware.com/appnote
11  *
12  * @author  Andreas Gohr <andi@splitbrain.org>
13  * @package splitbrain\PHPArchive
14  * @license MIT
15  */
16 class Zip extends Archive
17 {
18     const LOCAL_FILE_HEADER_CRC_OFFSET = 14;
19 
20     protected $file = '';
21     protected $fh;
22     protected $memory = '';
23     protected $closed = true;
24     protected $writeaccess = false;
25     protected $ctrl_dir;
26     protected $complevel = 9;
27 
28     /**
29      * Set the compression level.
30      *
31      * Compression Type is ignored for ZIP
32      *
33      * You can call this function before adding each file to set differen compression levels
34      * for each file.
35      *
36      * @param int $level Compression level (0 to 9)
37      * @param int $type  Type of compression to use ignored for ZIP
38      * @throws ArchiveIllegalCompressionException
39      */
40     public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO)
41     {
42         if ($level < -1 || $level > 9) {
43             throw new ArchiveIllegalCompressionException('Compression level should be between -1 and 9');
44         }
45         $this->complevel = $level;
46     }
47 
48     /**
49      * Open an existing ZIP file for reading
50      *
51      * @param string $file
52      * @throws ArchiveIOException
53      */
54     public function open($file)
55     {
56         $this->file = $file;
57         $this->fh   = @fopen($this->file, 'rb');
58         if (!$this->fh) {
59             throw new ArchiveIOException('Could not open file for reading: '.$this->file);
60         }
61         $this->closed = false;
62     }
63 
64     /**
65      * Read the contents of a ZIP archive
66      *
67      * This function lists the files stored in the archive, and returns an indexed array of FileInfo objects
68      *
69      * The archive is closed afer reading the contents, for API compatibility with TAR files
70      * Reopen the file with open() again if you want to do additional operations
71      *
72      * @throws ArchiveIOException
73      * @return FileInfo[]
74      */
75     public function contents()
76     {
77         $result = array();
78 
79         foreach ($this->yieldContents() as $fileinfo) {
80             $result[] = $fileinfo;
81         }
82 
83         return $result;
84     }
85 
86     /**
87      * Read the contents of a ZIP archive and return each entry using yield
88      * for memory efficiency.
89      *
90      * @see contents()
91      * @throws ArchiveIOException
92      * @return FileInfo[]
93      */
94     public function yieldContents()
95     {
96         if ($this->closed || !$this->file) {
97             throw new ArchiveIOException('Can not read from a closed archive');
98         }
99 
100         $centd = $this->readCentralDir();
101 
102         @rewind($this->fh);
103         @fseek($this->fh, $centd['offset']);
104 
105         for ($i = 0; $i < $centd['entries']; $i++) {
106             yield $this->header2fileinfo($this->readCentralFileHeader());
107         }
108 
109         $this->close();
110     }
111 
112     /**
113      * Extract an existing ZIP archive
114      *
115      * The $strip parameter allows you to strip a certain number of path components from the filenames
116      * found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
117      * an integer is passed as $strip.
118      * Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
119      * the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
120      *
121      * By default this will extract all files found in the archive. You can restrict the output using the $include
122      * and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
123      * $include is set only files that match this expression will be extracted. Files that match the $exclude
124      * expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
125      * stripped filenames as described above.
126      *
127      * @param string     $outdir  the target directory for extracting
128      * @param int|string $strip   either the number of path components or a fixed prefix to strip
129      * @param string     $exclude a regular expression of files to exclude
130      * @param string     $include a regular expression of files to include
131      * @throws ArchiveIOException
132      * @return FileInfo[]
133      */
134     public function extract($outdir, $strip = '', $exclude = '', $include = '')
135     {
136         if ($this->closed || !$this->file) {
137             throw new ArchiveIOException('Can not read from a closed archive');
138         }
139 
140         $outdir = rtrim($outdir, '/');
141         @mkdir($outdir, 0777, true);
142 
143         $extracted = array();
144 
145         $cdir      = $this->readCentralDir();
146         $pos_entry = $cdir['offset']; // begin of the central file directory
147 
148         for ($i = 0; $i < $cdir['entries']; $i++) {
149             // read file header
150             @fseek($this->fh, $pos_entry);
151             $header          = $this->readCentralFileHeader();
152             $header['index'] = $i;
153             $pos_entry       = ftell($this->fh); // position of the next file in central file directory
154             fseek($this->fh, $header['offset']); // seek to beginning of file header
155             $header   = $this->readFileHeader($header);
156             $fileinfo = $this->header2fileinfo($header);
157 
158             // apply strip rules
159             $fileinfo->strip($strip);
160 
161             // skip unwanted files
162             if (!strlen($fileinfo->getPath()) || !$fileinfo->matchExpression($include, $exclude)) {
163                 continue;
164             }
165 
166             $extracted[] = $fileinfo;
167 
168             // create output directory
169             $output    = $outdir.'/'.$fileinfo->getPath();
170             $directory = ($header['folder']) ? $output : dirname($output);
171             @mkdir($directory, 0777, true);
172 
173             // nothing more to do for directories
174             if ($fileinfo->getIsdir()) {
175                 if(is_callable($this->callback)) {
176                     call_user_func($this->callback, $fileinfo);
177                 }
178                 continue;
179             }
180 
181             // compressed files are written to temporary .gz file first
182             if ($header['compression'] == 0) {
183                 $extractto = $output;
184             } else {
185                 $extractto = $output.'.gz';
186             }
187 
188             // open file for writing
189             $fp = @fopen($extractto, "wb");
190             if (!$fp) {
191                 throw new ArchiveIOException('Could not open file for writing: '.$extractto);
192             }
193 
194             // prepend compression header
195             if ($header['compression'] != 0) {
196                 $binary_data = pack(
197                     'va1a1Va1a1',
198                     0x8b1f,
199                     chr($header['compression']),
200                     chr(0x00),
201                     time(),
202                     chr(0x00),
203                     chr(3)
204                 );
205                 fwrite($fp, $binary_data, 10);
206             }
207 
208             // read the file and store it on disk
209             $size = $header['compressed_size'];
210             while ($size != 0) {
211                 $read_size   = ($size < 2048 ? $size : 2048);
212                 $buffer      = fread($this->fh, $read_size);
213                 $binary_data = pack('a'.$read_size, $buffer);
214                 fwrite($fp, $binary_data, $read_size);
215                 $size -= $read_size;
216             }
217 
218             // finalize compressed file
219             if ($header['compression'] != 0) {
220                 $binary_data = pack('VV', $header['crc'], $header['size']);
221                 fwrite($fp, $binary_data, 8);
222             }
223 
224             // close file
225             fclose($fp);
226 
227             // unpack compressed file
228             if ($header['compression'] != 0) {
229                 $gzp = @gzopen($extractto, 'rb');
230                 if (!$gzp) {
231                     @unlink($extractto);
232                     throw new ArchiveIOException('Failed file extracting. gzip support missing?');
233                 }
234                 $fp = @fopen($output, 'wb');
235                 if (!$fp) {
236                     throw new ArchiveIOException('Could not open file for writing: '.$extractto);
237                 }
238 
239                 $size = $header['size'];
240                 while ($size != 0) {
241                     $read_size   = ($size < 2048 ? $size : 2048);
242                     $buffer      = gzread($gzp, $read_size);
243                     $binary_data = pack('a'.$read_size, $buffer);
244                     @fwrite($fp, $binary_data, $read_size);
245                     $size -= $read_size;
246                 }
247                 fclose($fp);
248                 gzclose($gzp);
249                 unlink($extractto); // remove temporary gz file
250             }
251 
252             @touch($output, $fileinfo->getMtime());
253             //FIXME what about permissions?
254             if(is_callable($this->callback)) {
255                 call_user_func($this->callback, $fileinfo);
256             }
257         }
258 
259         $this->close();
260         return $extracted;
261     }
262 
263     /**
264      * Create a new ZIP file
265      *
266      * If $file is empty, the zip file will be created in memory
267      *
268      * @param string $file
269      * @throws ArchiveIOException
270      */
271     public function create($file = '')
272     {
273         $this->file   = $file;
274         $this->memory = '';
275         $this->fh     = 0;
276 
277         if ($this->file) {
278             $this->fh = @fopen($this->file, 'wb');
279 
280             if (!$this->fh) {
281                 throw new ArchiveIOException('Could not open file for writing: '.$this->file);
282             }
283         }
284         $this->writeaccess = true;
285         $this->closed      = false;
286         $this->ctrl_dir    = array();
287     }
288 
289     /**
290      * Add a file to the current ZIP archive using an existing file in the filesystem
291      *
292      * @param string          $file     path to the original file
293      * @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data, empty to take from original
294      * @throws ArchiveIOException
295      */
296 
297     /**
298      * Add a file to the current archive using an existing file in the filesystem
299      *
300      * @param string $file path to the original file
301      * @param string|FileInfo $fileinfo either the name to use in archive (string) or a FileInfo oject with all meta data, empty to take from original
302      * @throws ArchiveIOException
303      * @throws FileInfoException
304      */
305     public function addFile($file, $fileinfo = '')
306     {
307         if (is_string($fileinfo)) {
308             $fileinfo = FileInfo::fromPath($file, $fileinfo);
309         }
310 
311         if ($this->closed) {
312             throw new ArchiveIOException('Archive has been closed, files can no longer be added');
313         }
314 
315         $fp = @fopen($file, 'rb');
316         if ($fp === false) {
317             throw new ArchiveIOException('Could not open file for reading: '.$file);
318         }
319 
320         $offset = $this->dataOffset();
321         $name   = $fileinfo->getPath();
322         $time   = $fileinfo->getMtime();
323 
324         // write local file header (temporary CRC and size)
325         $this->writebytes($this->makeLocalFileHeader(
326             $time,
327             0,
328             0,
329             0,
330             $name,
331             (bool) $this->complevel
332         ));
333 
334         // we store no encryption header
335 
336         // prepare info, compress and write data to archive
337         $deflate_context = deflate_init(ZLIB_ENCODING_DEFLATE, ['level' => $this->complevel]);
338         $crc_context = hash_init('crc32b');
339         $size = $csize = 0;
340 
341         while (!feof($fp)) {
342             $block = fread($fp, 512);
343 
344             if ($this->complevel) {
345                 $is_first_block = $size === 0;
346                 $is_last_block = feof($fp);
347 
348                 if ($is_last_block) {
349                     $c_block = deflate_add($deflate_context, $block, ZLIB_FINISH);
350                     // get rid of the compression footer
351                     $c_block = substr($c_block, 0, -4);
352                 } else {
353                     $c_block = deflate_add($deflate_context, $block, ZLIB_NO_FLUSH);
354                 }
355 
356                 // get rid of the compression header
357                 if ($is_first_block) {
358                     $c_block = substr($c_block, 2);
359                 }
360 
361                 $csize += strlen($c_block);
362                 $this->writebytes($c_block);
363             } else {
364                 $this->writebytes($block);
365             }
366 
367             $size += strlen($block);
368             hash_update($crc_context, $block);
369         }
370         fclose($fp);
371 
372         // update the local file header with the computed CRC and size
373         $crc = hexdec(hash_final($crc_context));
374         $csize = $this->complevel ? $csize : $size;
375         $this->writebytesAt($this->makeCrcAndSize(
376             $crc,
377             $size,
378             $csize
379         ), $offset + self::LOCAL_FILE_HEADER_CRC_OFFSET);
380 
381         // we store no data descriptor
382 
383         // add info to central file directory
384         $this->ctrl_dir[] = $this->makeCentralFileRecord(
385             $offset,
386             $time,
387             $crc,
388             $size,
389             $csize,
390             $name,
391             (bool) $this->complevel
392         );
393 
394         if(is_callable($this->callback)) {
395             call_user_func($this->callback, $fileinfo);
396         }
397     }
398 
399     /**
400      * Add a file to the current Zip archive using the given $data as content
401      *
402      * @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
403      * @param string          $data     binary content of the file to add
404      * @throws ArchiveIOException
405      */
406     public function addData($fileinfo, $data)
407     {
408         if (is_string($fileinfo)) {
409             $fileinfo = new FileInfo($fileinfo);
410         }
411 
412         if ($this->closed) {
413             throw new ArchiveIOException('Archive has been closed, files can no longer be added');
414         }
415 
416         // prepare info and compress data
417         $size     = strlen($data);
418         $crc      = crc32($data);
419         if ($this->complevel) {
420             $data = gzcompress($data, $this->complevel);
421             $data = substr($data, 2, -4); // strip compression headers
422         }
423         $csize  = strlen($data);
424         $offset = $this->dataOffset();
425         $name   = $fileinfo->getPath();
426         $time   = $fileinfo->getMtime();
427 
428         // write local file header
429         $this->writebytes($this->makeLocalFileHeader(
430             $time,
431             $crc,
432             $size,
433             $csize,
434             $name,
435             (bool) $this->complevel
436         ));
437 
438         // we store no encryption header
439 
440         // write data
441         $this->writebytes($data);
442 
443         // we store no data descriptor
444 
445         // add info to central file directory
446         $this->ctrl_dir[] = $this->makeCentralFileRecord(
447             $offset,
448             $time,
449             $crc,
450             $size,
451             $csize,
452             $name,
453             (bool) $this->complevel
454         );
455 
456         if(is_callable($this->callback)) {
457             call_user_func($this->callback, $fileinfo);
458         }
459     }
460 
461     /**
462      * Add the closing footer to the archive if in write mode, close all file handles
463      *
464      * After a call to this function no more data can be added to the archive, for
465      * read access no reading is allowed anymore
466      * @throws ArchiveIOException
467      */
468     public function close()
469     {
470         if ($this->closed) {
471             return;
472         } // we did this already
473 
474         if ($this->writeaccess) {
475             // write central directory
476             $offset = $this->dataOffset();
477             $ctrldir = join('', $this->ctrl_dir);
478             $this->writebytes($ctrldir);
479 
480             // write end of central directory record
481             $this->writebytes("\x50\x4b\x05\x06"); // end of central dir signature
482             $this->writebytes(pack('v', 0)); // number of this disk
483             $this->writebytes(pack('v', 0)); // number of the disk with the start of the central directory
484             $this->writebytes(pack('v',
485                 count($this->ctrl_dir))); // total number of entries in the central directory on this disk
486             $this->writebytes(pack('v', count($this->ctrl_dir))); // total number of entries in the central directory
487             $this->writebytes(pack('V', strlen($ctrldir))); // size of the central directory
488             $this->writebytes(pack('V',
489                 $offset)); // offset of start of central directory with respect to the starting disk number
490             $this->writebytes(pack('v', 0)); // .ZIP file comment length
491 
492             $this->ctrl_dir = array();
493         }
494 
495         // close file handles
496         if ($this->file) {
497             fclose($this->fh);
498             $this->file = '';
499             $this->fh   = 0;
500         }
501 
502         $this->writeaccess = false;
503         $this->closed      = true;
504     }
505 
506     /**
507      * Returns the created in-memory archive data
508      *
509      * This implicitly calls close() on the Archive
510      * @throws ArchiveIOException
511      */
512     public function getArchive()
513     {
514         $this->close();
515 
516         return $this->memory;
517     }
518 
519     /**
520      * Save the created in-memory archive data
521      *
522      * Note: It's more memory effective to specify the filename in the create() function and
523      * let the library work on the new file directly.
524      *
525      * @param     $file
526      * @throws ArchiveIOException
527      */
528     public function save($file)
529     {
530         if (!@file_put_contents($file, $this->getArchive())) {
531             throw new ArchiveIOException('Could not write to file: '.$file);
532         }
533     }
534 
535     /**
536      * Read the central directory
537      *
538      * This key-value list contains general information about the ZIP file
539      *
540      * @return array
541      */
542     protected function readCentralDir()
543     {
544         $size = filesize($this->file);
545         if ($size < 277) {
546             $maximum_size = $size;
547         } else {
548             $maximum_size = 277;
549         }
550 
551         @fseek($this->fh, $size - $maximum_size);
552         $pos   = ftell($this->fh);
553         $bytes = 0x00000000;
554 
555         while ($pos < $size) {
556             $byte  = @fread($this->fh, 1);
557             $bytes = (($bytes << 8) & 0xFFFFFFFF) | ord($byte);
558             if ($bytes == 0x504b0506) {
559                 break;
560             }
561             $pos++;
562         }
563 
564         $data = unpack(
565             'vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_size',
566             fread($this->fh, 18)
567         );
568 
569         if ($data['comment_size'] != 0) {
570             $centd['comment'] = fread($this->fh, $data['comment_size']);
571         } else {
572             $centd['comment'] = '';
573         }
574         $centd['entries']      = $data['entries'];
575         $centd['disk_entries'] = $data['disk_entries'];
576         $centd['offset']       = $data['offset'];
577         $centd['disk_start']   = $data['disk_start'];
578         $centd['size']         = $data['size'];
579         $centd['disk']         = $data['disk'];
580         return $centd;
581     }
582 
583     /**
584      * Read the next central file header
585      *
586      * Assumes the current file pointer is pointing at the right position
587      *
588      * @return array
589      */
590     protected function readCentralFileHeader()
591     {
592         $binary_data = fread($this->fh, 46);
593         $header      = unpack(
594             'vchkid/vid/vversion/vversion_extracted/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len/vcomment_len/vdisk/vinternal/Vexternal/Voffset',
595             $binary_data
596         );
597 
598         if ($header['filename_len'] != 0) {
599             $header['filename'] = fread($this->fh, $header['filename_len']);
600         } else {
601             $header['filename'] = '';
602         }
603 
604         if ($header['extra_len'] != 0) {
605             $header['extra'] = fread($this->fh, $header['extra_len']);
606             $header['extradata'] = $this->parseExtra($header['extra']);
607         } else {
608             $header['extra'] = '';
609             $header['extradata'] = array();
610         }
611 
612         if ($header['comment_len'] != 0) {
613             $header['comment'] = fread($this->fh, $header['comment_len']);
614         } else {
615             $header['comment'] = '';
616         }
617 
618         $header['mtime']           = $this->makeUnixTime($header['mdate'], $header['mtime']);
619         $header['stored_filename'] = $header['filename'];
620         $header['status']          = 'ok';
621         if (substr($header['filename'], -1) == '/') {
622             $header['external'] = 0x41FF0010;
623         }
624         $header['folder'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
625 
626         return $header;
627     }
628 
629     /**
630      * Reads the local file header
631      *
632      * This header precedes each individual file inside the zip file. Assumes the current file pointer is pointing at
633      * the right position already. Enhances the given central header with the data found at the local header.
634      *
635      * @param array $header the central file header read previously (see above)
636      * @return array
637      */
638     protected function readFileHeader($header)
639     {
640         $binary_data = fread($this->fh, 30);
641         $data        = unpack(
642             'vchk/vid/vversion/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len',
643             $binary_data
644         );
645 
646         $header['filename'] = fread($this->fh, $data['filename_len']);
647         if ($data['extra_len'] != 0) {
648             $header['extra'] = fread($this->fh, $data['extra_len']);
649             $header['extradata'] = array_merge($header['extradata'],  $this->parseExtra($header['extra']));
650         } else {
651             $header['extra'] = '';
652             $header['extradata'] = array();
653         }
654 
655         $header['compression'] = $data['compression'];
656         foreach (array(
657                      'size',
658                      'compressed_size',
659                      'crc'
660                  ) as $hd) { // On ODT files, these headers are 0. Keep the previous value.
661             if ($data[$hd] != 0) {
662                 $header[$hd] = $data[$hd];
663             }
664         }
665         $header['flag']  = $data['flag'];
666         $header['mtime'] = $this->makeUnixTime($data['mdate'], $data['mtime']);
667 
668         $header['stored_filename'] = $header['filename'];
669         $header['status']          = "ok";
670         $header['folder']          = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
671         return $header;
672     }
673 
674     /**
675      * Parse the extra headers into fields
676      *
677      * @param string $header
678      * @return array
679      */
680     protected function parseExtra($header)
681     {
682         $extra = array();
683         // parse all extra fields as raw values
684         while (strlen($header) !== 0) {
685             $set = unpack('vid/vlen', $header);
686             $header = substr($header, 4);
687             $value = substr($header, 0, $set['len']);
688             $header = substr($header, $set['len']);
689             $extra[$set['id']] = $value;
690         }
691 
692         // handle known ones
693         if(isset($extra[0x6375])) {
694             $extra['utf8comment'] = substr($extra[0x7075], 5); // strip version and crc
695         }
696         if(isset($extra[0x7075])) {
697             $extra['utf8path'] = substr($extra[0x7075], 5); // strip version and crc
698         }
699 
700         return $extra;
701     }
702 
703     /**
704      * Create fileinfo object from header data
705      *
706      * @param $header
707      * @return FileInfo
708      */
709     protected function header2fileinfo($header)
710     {
711         $fileinfo = new FileInfo();
712         $fileinfo->setSize($header['size']);
713         $fileinfo->setCompressedSize($header['compressed_size']);
714         $fileinfo->setMtime($header['mtime']);
715         $fileinfo->setComment($header['comment']);
716         $fileinfo->setIsdir($header['external'] == 0x41FF0010 || $header['external'] == 16);
717 
718         if(isset($header['extradata']['utf8path'])) {
719             $fileinfo->setPath($header['extradata']['utf8path']);
720         } else {
721             $fileinfo->setPath($this->cpToUtf8($header['filename']));
722         }
723 
724         if(isset($header['extradata']['utf8comment'])) {
725             $fileinfo->setComment($header['extradata']['utf8comment']);
726         } else {
727             $fileinfo->setComment($this->cpToUtf8($header['comment']));
728         }
729 
730         return $fileinfo;
731     }
732 
733     /**
734      * Convert the given CP437 encoded string to UTF-8
735      *
736      * Tries iconv with the correct encoding first, falls back to mbstring with CP850 which is
737      * similar enough. CP437 seems not to be available in mbstring. Lastly falls back to keeping the
738      * string as is, which is still better than nothing.
739      *
740      * On some systems iconv is available, but the codepage is not. We also check for that.
741      *
742      * @param $string
743      * @return string
744      */
745     protected function cpToUtf8($string)
746     {
747         if (function_exists('iconv') && @iconv_strlen('', 'CP437') !== false) {
748             return iconv('CP437', 'UTF-8', $string);
749         } elseif (function_exists('mb_convert_encoding')) {
750             return mb_convert_encoding($string, 'UTF-8', 'CP850');
751         } else {
752             return $string;
753         }
754     }
755 
756     /**
757      * Convert the given UTF-8 encoded string to CP437
758      *
759      * Same caveats as for cpToUtf8() apply
760      *
761      * @param $string
762      * @return string
763      */
764     protected function utf8ToCp($string)
765     {
766         // try iconv first
767         if (function_exists('iconv')) {
768             $conv = @iconv('UTF-8', 'CP437//IGNORE', $string);
769             if($conv) return $conv; // it worked
770         }
771 
772         // still here? iconv failed to convert the string. Try another method
773         // see http://php.net/manual/en/function.iconv.php#108643
774 
775         if (function_exists('mb_convert_encoding')) {
776             return mb_convert_encoding($string, 'CP850', 'UTF-8');
777         } else {
778             return $string;
779         }
780     }
781 
782 
783     /**
784      * Write to the open filepointer or memory
785      *
786      * @param string $data
787      * @throws ArchiveIOException
788      * @return int number of bytes written
789      */
790     protected function writebytes($data)
791     {
792         if (!$this->file) {
793             $this->memory .= $data;
794             $written = strlen($data);
795         } else {
796             $written = @fwrite($this->fh, $data);
797         }
798         if ($written === false) {
799             throw new ArchiveIOException('Failed to write to archive stream');
800         }
801         return $written;
802     }
803 
804     /**
805      * Write to the open filepointer or memory at the specified offset
806      *
807      * @param string $data
808      * @param int $offset
809      * @throws ArchiveIOException
810      * @return int number of bytes written
811      */
812     protected function writebytesAt($data, $offset) {
813         if (!$this->file) {
814             $this->memory .= substr_replace($this->memory, $data, $offset);
815             $written = strlen($data);
816         } else {
817             @fseek($this->fh, $offset);
818             $written = @fwrite($this->fh, $data);
819             @fseek($this->fh, 0, SEEK_END);
820         }
821         if ($written === false) {
822             throw new ArchiveIOException('Failed to write to archive stream');
823         }
824         return $written;
825     }
826 
827     /**
828      * Current data pointer position
829      *
830      * @fixme might need a -1
831      * @return int
832      */
833     protected function dataOffset()
834     {
835         if ($this->file) {
836             return ftell($this->fh);
837         } else {
838             return strlen($this->memory);
839         }
840     }
841 
842     /**
843      * Create a DOS timestamp from a UNIX timestamp
844      *
845      * DOS timestamps start at 1980-01-01, earlier UNIX stamps will be set to this date
846      *
847      * @param $time
848      * @return int
849      */
850     protected function makeDosTime($time)
851     {
852         $timearray = getdate($time);
853         if ($timearray['year'] < 1980) {
854             $timearray['year']    = 1980;
855             $timearray['mon']     = 1;
856             $timearray['mday']    = 1;
857             $timearray['hours']   = 0;
858             $timearray['minutes'] = 0;
859             $timearray['seconds'] = 0;
860         }
861         return (($timearray['year'] - 1980) << 25) |
862         ($timearray['mon'] << 21) |
863         ($timearray['mday'] << 16) |
864         ($timearray['hours'] << 11) |
865         ($timearray['minutes'] << 5) |
866         ($timearray['seconds'] >> 1);
867     }
868 
869     /**
870      * Create a UNIX timestamp from a DOS timestamp
871      *
872      * @param $mdate
873      * @param $mtime
874      * @return int
875      */
876     protected function makeUnixTime($mdate = null, $mtime = null)
877     {
878         if ($mdate && $mtime) {
879             $year = (($mdate & 0xFE00) >> 9) + 1980;
880             $month = ($mdate & 0x01E0) >> 5;
881             $day = $mdate & 0x001F;
882 
883             $hour = ($mtime & 0xF800) >> 11;
884             $minute = ($mtime & 0x07E0) >> 5;
885             $seconde = ($mtime & 0x001F) << 1;
886 
887             $mtime = mktime($hour, $minute, $seconde, $month, $day, $year);
888         } else {
889             $mtime = time();
890         }
891 
892         return $mtime;
893     }
894 
895     /**
896      * Returns a local file header for the given data
897      *
898      * @param int $offset location of the local header
899      * @param int $ts unix timestamp
900      * @param int $crc CRC32 checksum of the uncompressed data
901      * @param int $len length of the uncompressed data
902      * @param int $clen length of the compressed data
903      * @param string $name file name
904      * @param boolean|null $comp if compression is used, if null it's determined from $len != $clen
905      * @return string
906      */
907     protected function makeCentralFileRecord($offset, $ts, $crc, $len, $clen, $name, $comp = null)
908     {
909         if(is_null($comp)) $comp = $len != $clen;
910         $comp = $comp ? 8 : 0;
911         $dtime = dechex($this->makeDosTime($ts));
912 
913         list($name, $extra) = $this->encodeFilename($name);
914 
915         $header = "\x50\x4b\x01\x02"; // central file header signature
916         $header .= pack('v', 14); // version made by - VFAT
917         $header .= pack('v', 20); // version needed to extract - 2.0
918         $header .= pack('v', 0); // general purpose flag - no flags set
919         $header .= pack('v', $comp); // compression method - deflate|none
920         $header .= pack(
921             'H*',
922             $dtime[6] . $dtime[7] .
923             $dtime[4] . $dtime[5] .
924             $dtime[2] . $dtime[3] .
925             $dtime[0] . $dtime[1]
926         ); //  last mod file time and date
927         $header .= pack('V', $crc); // crc-32
928         $header .= pack('V', $clen); // compressed size
929         $header .= pack('V', $len); // uncompressed size
930         $header .= pack('v', strlen($name)); // file name length
931         $header .= pack('v', strlen($extra)); // extra field length
932         $header .= pack('v', 0); // file comment length
933         $header .= pack('v', 0); // disk number start
934         $header .= pack('v', 0); // internal file attributes
935         $header .= pack('V', 0); // external file attributes  @todo was 0x32!?
936         $header .= pack('V', $offset); // relative offset of local header
937         $header .= $name; // file name
938         $header .= $extra; // extra (utf-8 filename)
939 
940         return $header;
941     }
942 
943     /**
944      * Returns a local file header for the given data
945      *
946      * @param int $ts unix timestamp
947      * @param int $crc CRC32 checksum of the uncompressed data
948      * @param int $len length of the uncompressed data
949      * @param int $clen length of the compressed data
950      * @param string $name file name
951      * @param boolean|null $comp if compression is used, if null it's determined from $len != $clen
952      * @return string
953      */
954     protected function makeLocalFileHeader($ts, $crc, $len, $clen, $name, $comp = null)
955     {
956         if(is_null($comp)) $comp = $len != $clen;
957         $comp = $comp ? 8 : 0;
958         $dtime = dechex($this->makeDosTime($ts));
959 
960         list($name, $extra) = $this->encodeFilename($name);
961 
962         $header = "\x50\x4b\x03\x04"; //  local file header signature
963         $header .= pack('v', 20); // version needed to extract - 2.0
964         $header .= pack('v', 0); // general purpose flag - no flags set
965         $header .= pack('v', $comp); // compression method - deflate|none
966         $header .= pack(
967             'H*',
968             $dtime[6] . $dtime[7] .
969             $dtime[4] . $dtime[5] .
970             $dtime[2] . $dtime[3] .
971             $dtime[0] . $dtime[1]
972         ); //  last mod file time and date
973         $header .= pack('V', $crc); // crc-32
974         $header .= pack('V', $clen); // compressed size
975         $header .= pack('V', $len); // uncompressed size
976         $header .= pack('v', strlen($name)); // file name length
977         $header .= pack('v', strlen($extra)); // extra field length
978         $header .= $name; // file name
979         $header .= $extra; // extra (utf-8 filename)
980         return $header;
981     }
982 
983     /**
984      * Returns only a part of the local file header containing the CRC, size and compressed size.
985      * Used to update these fields for an already written header.
986      *
987      * @param int $crc CRC32 checksum of the uncompressed data
988      * @param int $len length of the uncompressed data
989      * @param int $clen length of the compressed data
990      * @return string
991      */
992     protected function makeCrcAndSize($crc, $len, $clen) {
993         $header  = pack('V', $crc); // crc-32
994         $header .= pack('V', $clen); // compressed size
995         $header .= pack('V', $len); // uncompressed size
996         return $header;
997     }
998 
999     /**
1000      * Returns an allowed filename and an extra field header
1001      *
1002      * When encoding stuff outside the 7bit ASCII range it needs to be placed in a separate
1003      * extra field
1004      *
1005      * @param $original
1006      * @return array($filename, $extra)
1007      */
1008     protected function encodeFilename($original)
1009     {
1010         $cp437 = $this->utf8ToCp($original);
1011         if ($cp437 === $original) {
1012             return array($original, '');
1013         }
1014 
1015         $extra = pack(
1016             'vvCV',
1017             0x7075, // tag
1018             strlen($original) + 5, // length of file + version + crc
1019             1, // version
1020             crc32($original) // crc
1021         );
1022         $extra .= $original;
1023 
1024         return array($cp437, $extra);
1025     }
1026 }
1027