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