1<?php
2/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
3
4/**
5 * ZIP archive reader
6 *
7 * PHP versions 4 and 5
8 *
9 * This library is free software; you can redistribute it and/or
10 * modify it under the terms of the GNU Lesser General Public
11 * License as published by the Free Software Foundation; either
12 * version 2.1 of the License, or (at your option) any later version.
13 *
14 * This library is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 * Lesser General Public License for more details.
18 *
19 * You should have received a copy of the GNU Lesser General Public
20 * License along with this library; if not, write to the Free Software
21 * Foundation, Inc., 59 Temple Place, Suite 330,Boston,MA 02111-1307 USA
22 *
23 * @category   File Formats
24 * @package    File_Archive
25 * @author     Vincent Lascaux <vincentlascaux@php.net>
26 * @copyright  1997-2005 The PHP Group
27 * @license    http://www.gnu.org/copyleft/lesser.html  LGPL
28 * @version    CVS: $Id: Zip.php,v 1.26 2005/06/19 20:09:58 vincentlascaux Exp $
29 * @link       http://pear.php.net/package/File_Archive
30 */
31
32require_once "File/Archive/Reader/Archive.php";
33
34/**
35 * ZIP archive reader
36 * Currently only allows to browse the archive (getData is not available)
37 */
38class File_Archive_Reader_Zip extends File_Archive_Reader_Archive
39{
40    var $currentFilename = null;
41    var $currentStat = null;
42    var $header = null;
43    var $offset = 0;
44    var $data = null;
45    var $files = array();
46    var $seekToEnd = 0;
47
48    var $centralDirectory = null;
49
50    /**
51     * @see File_Archive_Reader::close()
52     */
53    function close()
54    {
55        $this->currentFilename = null;
56        $this->currentStat = null;
57        $this->compLength = 0;
58        $this->data = null;
59        $this->seekToEnd = 0;
60        $this->files = array();
61        $this->centralDirectory = null;
62
63        return parent::close();
64    }
65
66    /**
67     * @see File_Archive_Reader::getFilename()
68     */
69    function getFilename() { return $this->currentFilename; }
70    /**
71     * @see File_Archive_Reader::getStat()
72     */
73    function getStat() { return $this->currentStat; }
74
75    /**
76     * Go to next entry in ZIP archive
77     * This function may stop on a folder, so it does not comply to the
78     * File_Archive_Reader::next specs
79     *
80     * @see File_Archive_Reader::next()
81     */
82    function nextWithFolders()
83    {
84        if ($this->seekToEnd > 0) {
85            return false;
86        }
87
88        //Skip the data and the footer if they haven't been uncompressed
89        if ($this->header !== null && $this->data === null) {
90            $toSkip = $this->header['CLen'];
91            $error = $this->source->skip($toSkip);
92            if (PEAR::isError($error)) {
93                return $error;
94            }
95        }
96
97        $this->offset = 0;
98        $this->data = null;
99
100        //Read the header
101        $header = $this->source->getData(4);
102        if (PEAR::isError($header)) {
103            return $header;
104        }
105        if ($header == "\x50\x4b\x03\x04") {
106            //New entry
107            $header = $this->source->getData(26);
108            if (PEAR::isError($header)) {
109                return $header;
110            }
111            $this->header = unpack(
112                "vVersion/vFlag/vMethod/vTime/vDate/VCRC/VCLen/VNLen/vFile/vExtra",
113                $header);
114
115            //Check the compression method
116            if ($this->header['Method'] != 0 &&
117                $this->header['Method'] != 8 &&
118                $this->header['Method'] != 12) {
119                return PEAR::raiseError("File_Archive_Reader_Zip doesn't ".
120                        "handle compression method {$this->header['Method']}");
121            }
122            if ($this->header['Flag'] & 1) {
123                return PEAR::raiseError("File_Archive_Reader_Zip doesn't ".
124                        "handle encrypted files");
125            }
126            if ($this->header['Flag'] & 8) {
127                if ($this->centralDirectory === null) {
128                    $this->readCentralDirectory();
129                }
130                $centralDirEntry = $this->centralDirectory[count($this->files)];
131
132                $this->header['CRC'] = $centralDirEntry['CRC'];
133                $this->header['CLen'] = $centralDirEntry['CLen'];
134                $this->header['NLen'] = $centralDirEntry['NLen'];
135            }
136            if ($this->header['Flag'] & 32) {
137                return PEAR::raiseError("File_Archive_Reader_Zip doesn't ".
138                        "handle compressed patched data");
139            }
140            if ($this->header['Flag'] & 64) {
141                return PEAR::raiseError("File_Archive_Reader_Zip doesn't ".
142                        "handle strong encrypted files");
143            }
144
145            $this->currentStat = array(
146                7=>$this->header['NLen'],
147                9=>mktime(
148                    ($this->header['Time'] & 0xF800) >> 11,         //hour
149                    ($this->header['Time'] & 0x07E0) >> 5,          //minute
150                    ($this->header['Time'] & 0x001F) >> 1,          //second
151                    ($this->header['Date'] & 0x01E0) >> 5,          //month
152                    ($this->header['Date'] & 0x001F)     ,          //day
153                   (($this->header['Date'] & 0xFE00) >> 9) + 1980   //year
154                )
155            );
156            $this->currentStat['size']  = $this->currentStat[7];
157            $this->currentStat['mtime'] = $this->currentStat[9];
158
159            $this->currentFilename = $this->source->getData($this->header['File']);
160
161            $error = $this->source->skip($this->header['Extra']);
162            if (PEAR::isError($error)) {
163                return $error;
164            }
165
166            $this->files[] = array('name' => $this->currentFilename,
167                            'stat' => $this->currentStat,
168                            'CRC' => $this->header['CRC'],
169                            'CLen' => $this->header['CLen']
170                           );
171
172            return true;
173        } else {
174            //Begining of central area
175            $this->seekToEnd = 4;
176            $this->currentFilename = null;
177            return false;
178        }
179    }
180    /**
181     * Go to next file entry in ZIP archive
182     * This function will not stop on a folder entry
183     * @see File_Archive_Reader::next()
184     */
185    function next()
186    {
187        if (!parent::next()) {
188            return false;
189        }
190
191        do {
192            $result = $this->nextWithFolders();
193            if ($result !== true) {
194                return $result;
195            }
196        } while (substr($this->getFilename(), -1) == '/');
197
198        return true;
199    }
200
201    /**
202     * @see File_Archive_Reader::getData()
203     */
204    function getData($length = -1)
205    {
206        if ($this->offset >= $this->currentStat[7]) {
207            return null;
208        }
209
210        if ($length>=0) {
211            $actualLength = min($length, $this->currentStat[7]-$this->offset);
212        } else {
213            $actualLength = $this->currentStat[7]-$this->offset;
214        }
215
216        $error = $this->uncompressData();
217        if (PEAR::isError($error)) {
218            return $error;
219        }
220        $result = substr($this->data, $this->offset, $actualLength);
221        $this->offset += $actualLength;
222        return $result;
223    }
224    /**
225     * @see File_Archive_Reader::skip()
226     */
227    function skip($length = -1)
228    {
229        $before = $this->offset;
230        if ($length == -1) {
231            $this->offset = $this->currentStat[7];
232        } else {
233            $this->offset = min($this->offset + $length, $this->currentStat[7]);
234        }
235        return $this->offset - $before;
236    }
237    /**
238     * @see File_Archive_Reader::rewind()
239     */
240    function rewind($length = -1)
241    {
242        $before = $this->offset;
243        if ($length == -1) {
244            $this->offset = 0;
245        } else {
246            $this->offset = min(0, $this->offset - $length);
247        }
248        return $before - $this->offset;
249    }
250    /**
251     * @see File_Archive_Reader::tell()
252     */
253    function tell()
254    {
255        return $this->offset;
256    }
257
258    function uncompressData()
259    {
260        if ($this->data !== null)
261            return;
262
263        $this->data = $this->source->getData($this->header['CLen']);
264        if (PEAR::isError($this->data)) {
265            return $this->data;
266        }
267        if ($this->header['Method'] == 8) {
268            $this->data = gzinflate($this->data);
269        }
270        if ($this->header['Method'] == 12) {
271            $this->data = bzdecompress($this->data);
272        }
273
274        if (crc32($this->data) != $this->header['CRC']) {
275            return PEAR::raiseError("Zip archive: CRC fails on entry ".
276                                    $this->currentFilename);
277        }
278    }
279
280    /**
281     * @see File_Archive_Reader::makeWriterRemoveFiles()
282     */
283    function makeWriterRemoveFiles($pred)
284    {
285        require_once "File/Archive/Writer/Zip.php";
286
287        $blocks = array();
288        $seek = null;
289        $gap = 0;
290        if ($this->currentFilename !== null && $pred->isTrue($this)) {
291            $seek = 30 + $this->header['File'] + $this->header['Extra'] + $this->header['CLen'];
292            $blocks[] = $seek; //Remove this file
293            array_pop($this->files);
294        }
295
296        while (($error = $this->nextWithFolders()) === true) {
297            $size = 30 + $this->header['File'] + $this->header['Extra'] + $this->header['CLen'];
298            if (substr($this->getFilename(), -1) == '/' || $pred->isTrue($this)) {
299                array_pop($this->files);
300                if ($seek === null) {
301                    $seek = $size;
302                    $blocks[] = $size;
303                } else if ($gap > 0) {
304                    $blocks[] = $gap; //Don't remove the files between the gap
305                    $blocks[] = $size;
306                    $seek += $size;
307                } else {
308                    $blocks[count($blocks)-1] += $size;   //Also remove this file
309                    $seek += $size;
310                }
311                $gap = 0;
312            } else {
313                if ($seek !== null) {
314                    $seek += $size;
315                    $gap += $size;
316                }
317            }
318        }
319        if (PEAR::isError($error)) {
320            return $error;
321        }
322
323        if ($seek === null) {
324            $seek = 4;
325        } else {
326            $seek += 4;
327            if ($gap == 0) {
328                array_pop($blocks);
329            } else {
330                $blocks[] = $gap;
331            }
332        }
333
334        $writer = new File_Archive_Writer_Zip(null,
335            $this->source->makeWriterRemoveBlocks($blocks, -$seek)
336        );
337        if (PEAR::isError($writer)) {
338            return $writer;
339        }
340
341        foreach ($this->files as $file) {
342            $writer->alreadyWrittenFile($file['name'], $file['stat'], $file['CRC'], $file['CLen']);
343        }
344
345        $this->close();
346        return $writer;
347    }
348
349    /**
350     * @see File_Archive_Reader::makeWriterRemoveBlocks()
351     */
352    function makeWriterRemoveBlocks($blocks, $seek = 0)
353    {
354        if ($this->currentFilename === null) {
355            return PEAR::raiseError('No file selected');
356        }
357
358        $keep = false;
359
360        $this->uncompressData();
361        $newData = substr($this->data, 0, $this->offset + $seek);
362        $this->data = substr($this->data, $this->offset + $seek);
363        foreach ($blocks as $length) {
364            if ($keep) {
365                $newData .= substr($this->data, 0, $length);
366            }
367            $this->data = substr($this->data, $length);
368            $keep = !$keep;
369        }
370        if ($keep) {
371            $newData .= $this->data;
372        }
373
374        $filename = $this->currentFilename;
375        $stat = $this->currentStat;
376
377        $writer = $this->makeWriterRemove();
378        if (PEAR::isError($writer)) {
379            return $writer;
380        }
381
382        unset($stat[7]);
383        $stat[9] = $stat['mtime'] = time();
384        $writer->newFile($filename, $stat);
385        $writer->writeData($newData);
386        return $writer;
387    }
388
389    /**
390     * @see File_Archive_Reader::makeAppendWriter
391     */
392    function makeAppendWriter()
393    {
394        require_once "File/Archive/Writer/Zip.php";
395
396        while (($error = $this->next()) === true) { }
397        if (PEAR::isError($error)) {
398            $this->close();
399            return $error;
400        }
401
402        $writer = new File_Archive_Writer_Zip(null,
403            $this->source->makeWriterRemoveBlocks(array(), -4)
404        );
405
406        foreach ($this->files as $file) {
407            $writer->alreadyWrittenFile($file['name'], $file['stat'], $file['CRC'], $file['CLen']);
408        }
409
410        $this->close();
411        return $writer;
412    }
413
414    /**
415     * This function seeks to the start of the [end of central directory] field,
416     * just after the \x50\x4b\x05\x06 signature and returns the number of bytes
417     * skipped
418     *
419     * The stream must initially be positioned before the end of central directory
420     */
421    function seekToEndOfCentralDirectory()
422    {
423        $nbSkipped = $this->source->skip();
424
425        $nbSkipped -= $this->source->rewind(22) - 4;
426        if ($this->source->getData(4) == "\x50\x4b\x05\x06") {
427            return $nbSkipped;
428        }
429
430        while ($nbSkipped > 0) {
431
432            $nbRewind = $this->source->rewind(min(100, $nbSkipped));
433            while ($nbRewind >= -4) {
434                if ($nbRewind-- && $this->source->getData(1) == "\x50" &&
435                    $nbRewind-- && $this->source->getData(1) == "\x4b" &&
436                    $nbRewind-- && $this->source->getData(1) == "\x05" &&
437                    $nbRewind-- && $this->source->getData(1) == "\x06") {
438                    //We finally found it!
439                    return $nbSkipped - $nbRewind;
440                }
441            }
442            $nbSkipped -= $nbRewind;
443        }
444
445        return PEAR::raiseError('End of central directory not found. The file is probably not a zip archive');
446    }
447
448    /**
449     * This function will fill the central directory variable
450     * and seek back to where it was called
451     */
452    function readCentralDirectory()
453    {
454        $nbSkipped = $this->seekToEndOfCentralDirectory();
455        if (PEAR::isError($nbSkipped)) {
456            return $nbSkipped;
457        }
458
459        $this->source->skip(12);
460        $offset = $this->source->getData(4);
461        $nbSkipped += 16;
462        if (PEAR::isError($offset)) {
463            return $offset;
464        }
465
466        $offset = unpack("Vvalue", $offset);
467        $offset = $offset['value'];
468
469        $current = $this->source->tell();
470        $nbSkipped -= $this->source->rewind($current - $offset);
471
472        //Now we are the right pos to read the central directory
473        $this->centralDirectory = array();
474        while ($this->source->getData(4) == "\x50\x4b\x01\x02") {
475            $this->source->skip(12);
476            $header = $this->source->getData(16);
477            $nbSkipped += 32;
478
479            if (PEAR::isError($header)) {
480                return $header;
481            }
482
483            $header = unpack('VCRC/VCLen/VNLen/vFileLength/vExtraLength', $header);
484            $this->centralDirectory[] = array('CRC'  => $header['CRC'],
485                                              'CLen' => $header['CLen'],
486                                              'NLen' => $header['NLen']);
487            $nbSkipped += $this->source->skip(14 + $header['FileLength'] + $header['ExtraLength']);
488        }
489
490        $this->source->rewind($nbSkipped+4);
491    }
492}
493?>