xref: /dokuwiki/vendor/splitbrain/php-archive/src/Zip.php (revision 9b886e96727c001a7e749f0fc3509eaaeb1bc39d)
1<?php
2
3namespace splitbrain\PHPArchive;
4
5/**
6 * Class Zip
7 *
8 * Creates or extracts Zip archives
9 *
10 * @author  Andreas Gohr <andi@splitbrain.org>
11 * @package splitbrain\PHPArchive
12 * @license MIT
13 */
14class Zip extends Archive
15{
16
17    protected $file = '';
18    protected $fh;
19    protected $memory = '';
20    protected $closed = true;
21    protected $writeaccess = false;
22    protected $ctrl_dir;
23    protected $complevel = 9;
24
25    /**
26     * Set the compression level.
27     *
28     * Compression Type is ignored for ZIP
29     *
30     * You can call this function before adding each file to set differen compression levels
31     * for each file.
32     *
33     * @param int $level Compression level (0 to 9)
34     * @param int $type  Type of compression to use ignored for ZIP
35     * @return mixed
36     */
37    public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO)
38    {
39        $this->complevel = $level;
40    }
41
42    /**
43     * Open an existing ZIP file for reading
44     *
45     * @param string $file
46     * @throws ArchiveIOException
47     */
48    public function open($file)
49    {
50        $this->file = $file;
51        $this->fh   = @fopen($this->file, 'rb');
52        if (!$this->fh) {
53            throw new ArchiveIOException('Could not open file for reading: '.$this->file);
54        }
55        $this->closed = false;
56    }
57
58    /**
59     * Read the contents of a ZIP archive
60     *
61     * This function lists the files stored in the archive, and returns an indexed array of FileInfo objects
62     *
63     * The archive is closed afer reading the contents, for API compatibility with TAR files
64     * Reopen the file with open() again if you want to do additional operations
65     *
66     * @throws ArchiveIOException
67     * @return FileInfo[]
68     */
69    public function contents()
70    {
71        if ($this->closed || !$this->file) {
72            throw new ArchiveIOException('Can not read from a closed archive');
73        }
74
75        $result = array();
76
77        $centd = $this->readCentralDir();
78
79        @rewind($this->fh);
80        @fseek($this->fh, $centd['offset']);
81
82        for ($i = 0; $i < $centd['entries']; $i++) {
83            $result[] = $this->header2fileinfo($this->readCentralFileHeader());
84        }
85
86        $this->close();
87        return $result;
88    }
89
90    /**
91     * Extract an existing ZIP archive
92     *
93     * The $strip parameter allows you to strip a certain number of path components from the filenames
94     * found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
95     * an integer is passed as $strip.
96     * Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
97     * the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
98     *
99     * By default this will extract all files found in the archive. You can restrict the output using the $include
100     * and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
101     * $include is set only files that match this expression will be extracted. Files that match the $exclude
102     * expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
103     * stripped filenames as described above.
104     *
105     * @param string     $outdir  the target directory for extracting
106     * @param int|string $strip   either the number of path components or a fixed prefix to strip
107     * @param string     $exclude a regular expression of files to exclude
108     * @param string     $include a regular expression of files to include
109     * @throws ArchiveIOException
110     * @return FileInfo[]
111     */
112    function extract($outdir, $strip = '', $exclude = '', $include = '')
113    {
114        if ($this->closed || !$this->file) {
115            throw new ArchiveIOException('Can not read from a closed archive');
116        }
117
118        $outdir = rtrim($outdir, '/');
119        @mkdir($outdir, 0777, true);
120
121        $extracted = array();
122
123        $cdir      = $this->readCentralDir();
124        $pos_entry = $cdir['offset']; // begin of the central file directory
125
126        for ($i = 0; $i < $cdir['entries']; $i++) {
127            // read file header
128            @fseek($this->fh, $pos_entry);
129            $header          = $this->readCentralFileHeader();
130            $header['index'] = $i;
131            $pos_entry       = ftell($this->fh); // position of the next file in central file directory
132            fseek($this->fh, $header['offset']); // seek to beginning of file header
133            $header   = $this->readFileHeader($header);
134            $fileinfo = $this->header2fileinfo($header);
135
136            // apply strip rules
137            $fileinfo->strip($strip);
138
139            // skip unwanted files
140            if (!strlen($fileinfo->getPath()) || !$fileinfo->match($include, $exclude)) {
141                continue;
142            }
143
144            $extracted[] = $fileinfo;
145
146            // create output directory
147            $output    = $outdir.'/'.$fileinfo->getPath();
148            $directory = ($header['folder']) ? $output : dirname($output);
149            @mkdir($directory, 0777, true);
150
151            // nothing more to do for directories
152            if ($fileinfo->getIsdir()) {
153                continue;
154            }
155
156            // compressed files are written to temporary .gz file first
157            if ($header['compression'] == 0) {
158                $extractto = $output;
159            } else {
160                $extractto = $output.'.gz';
161            }
162
163            // open file for writing
164            $fp = fopen($extractto, "wb");
165            if (!$fp) {
166                throw new ArchiveIOException('Could not open file for writing: '.$extractto);
167            }
168
169            // prepend compression header
170            if ($header['compression'] != 0) {
171                $binary_data = pack(
172                    'va1a1Va1a1',
173                    0x8b1f,
174                    chr($header['compression']),
175                    chr(0x00),
176                    time(),
177                    chr(0x00),
178                    chr(3)
179                );
180                fwrite($fp, $binary_data, 10);
181            }
182
183            // read the file and store it on disk
184            $size = $header['compressed_size'];
185            while ($size != 0) {
186                $read_size   = ($size < 2048 ? $size : 2048);
187                $buffer      = fread($this->fh, $read_size);
188                $binary_data = pack('a'.$read_size, $buffer);
189                fwrite($fp, $binary_data, $read_size);
190                $size -= $read_size;
191            }
192
193            // finalize compressed file
194            if ($header['compression'] != 0) {
195                $binary_data = pack('VV', $header['crc'], $header['size']);
196                fwrite($fp, $binary_data, 8);
197            }
198
199            // close file
200            fclose($fp);
201
202            // unpack compressed file
203            if ($header['compression'] != 0) {
204                $gzp = @gzopen($extractto, 'rb');
205                if (!$gzp) {
206                    @unlink($extractto);
207                    throw new ArchiveIOException('Failed file extracting. gzip support missing?');
208                }
209                $fp = @fopen($output, 'wb');
210                if (!$fp) {
211                    throw new ArchiveIOException('Could not open file for writing: '.$extractto);
212                }
213
214                $size = $header['size'];
215                while ($size != 0) {
216                    $read_size   = ($size < 2048 ? $size : 2048);
217                    $buffer      = gzread($gzp, $read_size);
218                    $binary_data = pack('a'.$read_size, $buffer);
219                    @fwrite($fp, $binary_data, $read_size);
220                    $size -= $read_size;
221                }
222                fclose($fp);
223                gzclose($gzp);
224            }
225
226            touch($output, $fileinfo->getMtime());
227            //FIXME what about permissions?
228        }
229
230        $this->close();
231        return $extracted;
232    }
233
234    /**
235     * Create a new ZIP file
236     *
237     * If $file is empty, the zip file will be created in memory
238     *
239     * @param string $file
240     * @throws ArchiveIOException
241     */
242    public function create($file = '')
243    {
244        $this->file   = $file;
245        $this->memory = '';
246        $this->fh     = 0;
247
248        if ($this->file) {
249            $this->fh = @fopen($this->file, 'wb');
250
251            if (!$this->fh) {
252                throw new ArchiveIOException('Could not open file for writing: '.$this->file);
253            }
254        }
255        $this->writeaccess = true;
256        $this->closed      = false;
257        $this->ctrl_dir    = array();
258    }
259
260    /**
261     * Add a file to the current ZIP archive using an existing file in the filesystem
262     *
263     * @param string          $file     path to the original file
264     * @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
265     * @throws ArchiveIOException
266     */
267
268    /**
269     * Add a file to the current archive using an existing file in the filesystem
270     *
271     * @param string          $file     path to the original file
272     * @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
273     * @throws ArchiveIOException
274     */
275    public function addFile($file, $fileinfo = '')
276    {
277        if (is_string($fileinfo)) {
278            $fileinfo = FileInfo::fromPath($file, $fileinfo);
279        }
280
281        if ($this->closed) {
282            throw new ArchiveIOException('Archive has been closed, files can no longer be added');
283        }
284
285        $data = @file_get_contents($file);
286        if ($data === false) {
287            throw new ArchiveIOException('Could not open file for reading: '.$file);
288        }
289
290        // FIXME could we stream writing compressed data? gzwrite on a fopen handle?
291        $this->addData($fileinfo, $data);
292    }
293
294    /**
295     * Add a file to the current TAR archive using the given $data as content
296     *
297     * @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
298     * @param string          $data     binary content of the file to add
299     * @throws ArchiveIOException
300     */
301    public function addData($fileinfo, $data)
302    {
303        if (is_string($fileinfo)) {
304            $fileinfo = new FileInfo($fileinfo);
305        }
306
307        if ($this->closed) {
308            throw new ArchiveIOException('Archive has been closed, files can no longer be added');
309        }
310
311        // prepare the various header infos
312        $dtime    = dechex($this->makeDosTime($fileinfo->getMtime()));
313        $hexdtime = pack(
314            'H*',
315            $dtime[6].$dtime[7].
316            $dtime[4].$dtime[5].
317            $dtime[2].$dtime[3].
318            $dtime[0].$dtime[1]
319        );
320        $size     = strlen($data);
321        $crc      = crc32($data);
322        if ($this->complevel) {
323            $fmagic = "\x50\x4b\x03\x04\x14\x00\x00\x00\x08\x00";
324            $cmagic = "\x50\x4b\x01\x02\x00\x00\x14\x00\x00\x00\x08\x00";
325            $data   = gzcompress($data, $this->complevel);
326            $data   = substr($data, 2, -4); // strip compression headers
327        } else {
328            $fmagic = "\x50\x4b\x03\x04\x0a\x00\x00\x00\x00\x00";
329            $cmagic = "\x50\x4b\x01\x02\x14\x00\x0a\x00\x00\x00\x00\x00";
330        }
331        $csize  = strlen($data);
332        $offset = $this->dataOffset();
333        $name   = $fileinfo->getPath();
334
335        // write data
336        $this->writebytes($fmagic);
337        $this->writebytes($hexdtime);
338        $this->writebytes(pack('V', $crc).pack('V', $csize).pack('V', $size)); //pre header
339        $this->writebytes(pack('v', strlen($name)).pack('v', 0).$name.$data); //file data
340        $this->writebytes(pack('V', $crc).pack('V', $csize).pack('V', $size)); //post header
341
342        // add info to central file directory
343        $cdrec = $cmagic;
344        $cdrec .= $hexdtime.pack('V', $crc).pack('V', $csize).pack('V', $size);
345        $cdrec .= pack('v', strlen($name)).pack('v', 0).pack('v', 0);
346        $cdrec .= pack('v', 0).pack('v', 0).pack('V', 32);
347        $cdrec .= pack('V', $offset);
348        $cdrec .= $name;
349        $this->ctrl_dir[] = $cdrec;
350    }
351
352    /**
353     * Add the closing footer to the archive if in write mode, close all file handles
354     *
355     * After a call to this function no more data can be added to the archive, for
356     * read access no reading is allowed anymore
357     */
358    public function close()
359    {
360        if ($this->closed) {
361            return;
362        } // we did this already
363
364        // write footer
365        if ($this->writeaccess) {
366            $offset  = $this->dataOffset();
367            $ctrldir = join('', $this->ctrl_dir);
368            $this->writebytes($ctrldir);
369            $this->writebytes("\x50\x4b\x05\x06\x00\x00\x00\x00"); // EOF CTRL DIR
370            $this->writebytes(pack('v', count($this->ctrl_dir)).pack('v', count($this->ctrl_dir)));
371            $this->writebytes(pack('V', strlen($ctrldir)).pack('V', strlen($offset))."\x00\x00");
372            $this->ctrl_dir = array();
373        }
374
375        // close file handles
376        if ($this->file) {
377            fclose($this->fh);
378            $this->file = '';
379            $this->fh   = 0;
380        }
381
382        $this->writeaccess = false;
383        $this->closed      = true;
384    }
385
386    /**
387     * Returns the created in-memory archive data
388     *
389     * This implicitly calls close() on the Archive
390     */
391    public function getArchive()
392    {
393        $this->close();
394
395        return $this->memory;
396    }
397
398    /**
399     * Save the created in-memory archive data
400     *
401     * Note: It's more memory effective to specify the filename in the create() function and
402     * let the library work on the new file directly.
403     *
404     * @param     $file
405     * @throws ArchiveIOException
406     */
407    public function save($file)
408    {
409        if (!file_put_contents($file, $this->getArchive())) {
410            throw new ArchiveIOException('Could not write to file: '.$file);
411        }
412    }
413
414    /**
415     * Read the central directory
416     *
417     * This key-value list contains general information about the ZIP file
418     *
419     * @return array
420     */
421    protected function readCentralDir()
422    {
423        $size = filesize($this->file);
424        if ($size < 277) {
425            $maximum_size = $size;
426        } else {
427            $maximum_size = 277;
428        }
429
430        @fseek($this->fh, $size - $maximum_size);
431        $pos   = ftell($this->fh);
432        $bytes = 0x00000000;
433
434        while ($pos < $size) {
435            $byte  = @fread($this->fh, 1);
436            $bytes = (($bytes << 8) & 0xFFFFFFFF) | ord($byte);
437            if ($bytes == 0x504b0506) {
438                break;
439            }
440            $pos++;
441        }
442
443        $data = unpack(
444            'vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_size',
445            fread($this->fh, 18)
446        );
447
448        if ($data['comment_size'] != 0) {
449            $centd['comment'] = fread($this->fh, $data['comment_size']);
450        } else {
451            $centd['comment'] = '';
452        }
453        $centd['entries']      = $data['entries'];
454        $centd['disk_entries'] = $data['disk_entries'];
455        $centd['offset']       = $data['offset'];
456        $centd['disk_start']   = $data['disk_start'];
457        $centd['size']         = $data['size'];
458        $centd['disk']         = $data['disk'];
459        return $centd;
460    }
461
462    /**
463     * Read the next central file header
464     *
465     * Assumes the current file pointer is pointing at the right position
466     *
467     * @return array
468     */
469    protected function readCentralFileHeader()
470    {
471        $binary_data = fread($this->fh, 46);
472        $header      = unpack(
473            'vchkid/vid/vversion/vversion_extracted/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len/vcomment_len/vdisk/vinternal/Vexternal/Voffset',
474            $binary_data
475        );
476
477        if ($header['filename_len'] != 0) {
478            $header['filename'] = fread($this->fh, $header['filename_len']);
479        } else {
480            $header['filename'] = '';
481        }
482
483        if ($header['extra_len'] != 0) {
484            $header['extra'] = fread($this->fh, $header['extra_len']);
485        } else {
486            $header['extra'] = '';
487        }
488
489        if ($header['comment_len'] != 0) {
490            $header['comment'] = fread($this->fh, $header['comment_len']);
491        } else {
492            $header['comment'] = '';
493        }
494
495        if ($header['mdate'] && $header['mtime']) {
496            $hour            = ($header['mtime'] & 0xF800) >> 11;
497            $minute          = ($header['mtime'] & 0x07E0) >> 5;
498            $seconde         = ($header['mtime'] & 0x001F) * 2;
499            $year            = (($header['mdate'] & 0xFE00) >> 9) + 1980;
500            $month           = ($header['mdate'] & 0x01E0) >> 5;
501            $day             = $header['mdate'] & 0x001F;
502            $header['mtime'] = mktime($hour, $minute, $seconde, $month, $day, $year);
503        } else {
504            $header['mtime'] = time();
505        }
506
507        $header['stored_filename'] = $header['filename'];
508        $header['status']          = 'ok';
509        if (substr($header['filename'], -1) == '/') {
510            $header['external'] = 0x41FF0010;
511        }
512        $header['folder'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
513
514        return $header;
515    }
516
517    /**
518     * Reads the local file header
519     *
520     * This header precedes each individual file inside the zip file. Assumes the current file pointer is pointing at
521     * the right position already. Enhances this given central header with the data found at the local header.
522     *
523     * @param array $header the central file header read previously (see above)
524     * @return array
525     */
526    function readFileHeader($header)
527    {
528        $binary_data = fread($this->fh, 30);
529        $data        = unpack(
530            'vchk/vid/vversion/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len',
531            $binary_data
532        );
533
534        $header['filename'] = fread($this->fh, $data['filename_len']);
535        if ($data['extra_len'] != 0) {
536            $header['extra'] = fread($this->fh, $data['extra_len']);
537        } else {
538            $header['extra'] = '';
539        }
540
541        $header['compression'] = $data['compression'];
542        foreach (array(
543                     'size',
544                     'compressed_size',
545                     'crc'
546                 ) as $hd) { // On ODT files, these headers are 0. Keep the previous value.
547            if ($data[$hd] != 0) {
548                $header[$hd] = $data[$hd];
549            }
550        }
551        $header['flag']  = $data['flag'];
552        $header['mdate'] = $data['mdate'];
553        $header['mtime'] = $data['mtime'];
554
555        if ($header['mdate'] && $header['mtime']) {
556            $hour            = ($header['mtime'] & 0xF800) >> 11;
557            $minute          = ($header['mtime'] & 0x07E0) >> 5;
558            $seconde         = ($header['mtime'] & 0x001F) * 2;
559            $year            = (($header['mdate'] & 0xFE00) >> 9) + 1980;
560            $month           = ($header['mdate'] & 0x01E0) >> 5;
561            $day             = $header['mdate'] & 0x001F;
562            $header['mtime'] = mktime($hour, $minute, $seconde, $month, $day, $year);
563        } else {
564            $header['mtime'] = time();
565        }
566
567        $header['stored_filename'] = $header['filename'];
568        $header['status']          = "ok";
569        $header['folder']          = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
570        return $header;
571    }
572
573    /**
574     * Create fileinfo object from header data
575     *
576     * @param $header
577     * @return FileInfo
578     */
579    protected function header2fileinfo($header)
580    {
581        $fileinfo = new FileInfo();
582        $fileinfo->setPath($header['filename']);
583        $fileinfo->setSize($header['size']);
584        $fileinfo->setCompressedSize($header['compressed_size']);
585        $fileinfo->setMtime($header['mtime']);
586        $fileinfo->setComment($header['comment']);
587        $fileinfo->setIsdir($header['external'] == 0x41FF0010 || $header['external'] == 16);
588        return $fileinfo;
589    }
590
591    /**
592     * Write to the open filepointer or memory
593     *
594     * @param string $data
595     * @throws ArchiveIOException
596     * @return int number of bytes written
597     */
598    protected function writebytes($data)
599    {
600        if (!$this->file) {
601            $this->memory .= $data;
602            $written = strlen($data);
603        } else {
604            $written = @fwrite($this->fh, $data);
605        }
606        if ($written === false) {
607            throw new ArchiveIOException('Failed to write to archive stream');
608        }
609        return $written;
610    }
611
612    /**
613     * Current data pointer position
614     *
615     * @fixme might need a -1
616     * @return int
617     */
618    protected function dataOffset()
619    {
620        if ($this->file) {
621            return ftell($this->fh);
622        } else {
623            return strlen($this->memory);
624        }
625    }
626
627    /**
628     * Create a DOS timestamp from a UNIX timestamp
629     *
630     * DOS timestamps start at 1980-01-01, earlier UNIX stamps will be set to this date
631     *
632     * @param $time
633     * @return int
634     */
635    protected function makeDosTime($time)
636    {
637        $timearray = getdate($time);
638        if ($timearray['year'] < 1980) {
639            $timearray['year']    = 1980;
640            $timearray['mon']     = 1;
641            $timearray['mday']    = 1;
642            $timearray['hours']   = 0;
643            $timearray['minutes'] = 0;
644            $timearray['seconds'] = 0;
645        }
646        return (($timearray['year'] - 1980) << 25) |
647        ($timearray['mon'] << 21) |
648        ($timearray['mday'] << 16) |
649        ($timearray['hours'] << 11) |
650        ($timearray['minutes'] << 5) |
651        ($timearray['seconds'] >> 1);
652    }
653
654}
655