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