1<?php
2
3/**
4 * Copyright (C) 2014-2015 Nicolai Ehemann <en@enlightened.de>
5 *
6 * This file is licensed under the GNU GPL version 3 or later.
7 * See COPYING for details.
8 */
9namespace ZipStreamer;
10
11require_once  __DIR__ . "/ZipComponents.php";
12
13class File {
14  const FILE = 1;
15  const DIR = 2;
16  public $filename;
17  public $date;
18  public $type;
19  public $data;
20
21  public function __construct($filename, $type, $date, $data = "") {
22    $this->filename = $filename;
23    $this->type = $type;
24    $this->date = $date;
25    $this->data = $data;
26  }
27
28  public function getSize() {
29    return strlen($this->data);
30  }
31}
32
33class TestZipStreamer extends \PHPUnit\Framework\TestCase {
34  const ATTR_MADE_BY_VERSION = 0x032d; // made by version (upper byte: UNIX, lower byte v4.5)
35  const EXT_FILE_ATTR_DIR = 0x41ed0010;
36  const EXT_FILE_ATTR_FILE = 0x81a40000;
37  protected $outstream;
38
39  protected function setUp() {
40    parent::setUp();
41    $this->outstream = fopen('php://memory', 'rw');
42    zipRecord::setUnitTest($this);
43  }
44
45  protected function tearDown() {
46    fclose($this->outstream);
47    parent::tearDown();
48  }
49
50  protected function getOutput() {
51    rewind($this->outstream);
52    return stream_get_contents($this->outstream);
53  }
54
55  protected static function getVersionToExtract($zip64, $isDir) {
56    if ($zip64) {
57      $version = 0x2d; // 4.5 - File uses ZIP64 format extensions
58    } else if ($isDir) {
59      $version = 0x14; // 2.0 - File is a folder (directory)
60    } else {
61      $version = 0x0a; // 1.0 - Default value
62    }
63    return $version;
64  }
65
66  protected function assertOutputEqualsFile($filename) {
67    $this->assertEquals(file_get_contents($filename), $this->getOutput());
68  }
69
70  protected function assertContainsOneMatch($pattern, $input) {
71    $results = preg_grep($pattern, $input);
72    $this->assertEquals(1, sizeof($results));
73  }
74
75  protected function assertOutputZipfileOK($files, $options) {
76    if (0 < sizeof($files)) { // php5.3 does not combine empty arrays
77      $files = array_combine(array_map(function ($element) {
78        return $element->filename;
79      }, $files), $files);
80    }
81    $output = $this->getOutput();
82
83    $eocdrec = EndOfCentralDirectoryRecord::constructFromString($output);
84    $this->assertEquals(strlen($output) - 1, $eocdrec->end, "EOCDR last item in file");
85
86    if ($options['zip64']) {
87      $eocdrec->assertValues(array(
88          "numberDisk" => 0xffff,
89          "numberDiskStartCD" => 0xffff,
90          "numberEntriesDisk" => sizeof($files),
91          "numberEntriesCD" => sizeof($files),
92          "size" => 0xffffffff,
93          "offsetStart" => 0xffffffff,
94          "lengthComment" => 0,
95          "comment" => ''
96      ));
97
98      $z64eocdloc = Zip64EndOfCentralDirectoryLocator::constructFromString($output, strlen($output) - ($eocdrec->begin + 1));
99
100      $this->assertEquals($z64eocdloc->end + 1, $eocdrec->begin, "Z64EOCDL directly before EOCDR");
101      $z64eocdloc->assertValues(array(
102          "numberDiskStartZ64EOCDL" => 0,
103          "numberDisks" => 1
104      ));
105
106      $z64eocdrec = Zip64EndOfCentralDirectoryRecord::constructFromString($output, strlen($output) - ($z64eocdloc->begin + 1));
107
108      $this->assertEquals(Count64::construct($z64eocdrec->begin), $z64eocdloc->offsetStart, "Z64EOCDR begin");
109      $this->assertEquals($z64eocdrec->end + 1, $z64eocdloc->begin, "Z64EOCDR directly before Z64EOCDL");
110      $z64eocdrec->assertValues(array(
111          "size" => Count64::construct(44),
112          "madeByVersion" => pack16le(self::ATTR_MADE_BY_VERSION),
113          "versionToExtract" => pack16le($this->getVersionToExtract($options['zip64'], False)),
114          "numberDisk" => 0,
115          "numberDiskStartCDR" => 0,
116          "numberEntriesDisk" => Count64::construct(sizeof($files)),
117          "numberEntriesCD" => Count64::construct(sizeof($files))
118      ));
119      $sizeCD = $z64eocdrec->sizeCD->getLoBytes();
120      $offsetCD = $z64eocdrec->offsetStart->getLoBytes();
121      $beginFollowingRecord = $z64eocdrec->begin;
122    } else {
123      $eocdrec->assertValues(array(
124          "numberDisk" => 0,
125          "numberDiskStartCD" => 0,
126          "numberEntriesDisk" => sizeof($files),
127          "numberEntriesCD" => sizeof($files),
128          "lengthComment" => 0,
129          "comment" => ''
130      ));
131      $sizeCD = $eocdrec->size;
132      $offsetCD = $eocdrec->offsetStart;
133      $beginFollowingRecord = $eocdrec->begin;
134    }
135
136    $cdheaders = array();
137    $pos = $offsetCD;
138    $cdhead = null;
139
140    while ($pos < $beginFollowingRecord) {
141      $cdhead = CentralDirectoryHeader::constructFromString($output, $pos);
142      $filename = $cdhead->filename;
143      $pos = $cdhead->end + 1;
144      $cdheaders[$filename] = $cdhead;
145
146      $this->assertArrayHasKey($filename, $files, "CDH entry has valid name");
147      $cdhead->assertValues(array(
148          "madeByVersion" => pack16le(self::ATTR_MADE_BY_VERSION),
149          "versionToExtract" => pack16le($this->getVersionToExtract($options['zip64'], File::DIR == $files[$filename]->type)),
150          "gpFlags" => (File::FILE == $files[$filename]->type ? pack16le(GPFLAGS::ADD) : pack16le(GPFLAGS::NONE)),
151          "gzMethod" => (File::FILE == $files[$filename]->type ? pack16le($options['compress']) : pack16le(COMPR::STORE)),
152          "dosTime" => pack32le(ZipStreamer::getDosTime($files[$filename]->date)),
153          "lengthFilename" => strlen($filename),
154          "lengthComment" => 0,
155          "fileAttrInternal" => pack16le(0x0000),
156          "fileAttrExternal" => (File::FILE == $files[$filename]->type ? pack32le(self::EXT_FILE_ATTR_FILE) : pack32le(self::EXT_FILE_ATTR_DIR))
157      ));
158      if ($options['zip64']) {
159        $cdhead->assertValues(array(
160            "sizeCompressed" => 0xffffffff,
161            "size" => 0xffffffff,
162            "lengthExtraField" => 32,
163            "diskNumberStart" => 0xffff,
164            "offsetStart" => 0xffffffff
165        ));
166        $cdhead->z64Ext->assertValues(array(
167            "sizeField" => 28,
168            "size" => Count64::construct($files[$filename]->getSize()),
169            "diskNumberStart" => 0
170        ));
171      } else {
172        $cdhead->assertValues(array(
173            "size" => $files[$filename]->getSize(),
174            "lengthExtraField" => 0,
175            "diskNumberStart" => 0
176        ));
177      }
178    }
179    if (0 < sizeof($files)) {
180      $this->assertEquals($cdhead->end + 1, $beginFollowingRecord, "CDH directly before following record");
181      $this->assertEquals(sizeof($files), sizeof($cdheaders), "CDH has correct number of entries");
182      $this->assertEquals($sizeCD, $beginFollowingRecord - $offsetCD, "CDH has correct size");
183    } else {
184      $this->assertNull($cdhead);
185    }
186
187    $first = True;
188    foreach ($cdheaders as $filename => $cdhead) {
189      if ($options['zip64']) {
190        $sizeCompressed = $cdhead->z64Ext->sizeCompressed->getLoBytes();
191        $offsetStart = $cdhead->z64Ext->offsetStart->getLoBytes();
192      } else {
193        $sizeCompressed = $cdhead->sizeCompressed;
194        $offsetStart = $cdhead->offsetStart;
195      }
196      if ($first) {
197        $this->assertEquals(0, $offsetStart, "first file directly at beginning of zipfile");
198      } else {
199        $this->assertEquals($endLastFile + 1, $offsetStart, "file immediately after last file");
200      }
201      $file = FileEntry::constructFromString($output, $offsetStart, $sizeCompressed);
202      $this->assertEquals($files[$filename]->data, $file->data);
203      $this->assertEquals(crc32($files[$filename]->data), $cdhead->dataCRC32);
204      if (GPFLAGS::ADD & $file->lfh->gpFlags) {
205        $this->assertNotNull($file->dd, "data descriptor present (flag ADD set)");
206      }
207      if ($options['zip64']) {
208        $file->lfh->assertValues(array(
209            "sizeCompressed" => 0xffffffff,
210            "size" => 0xffffffff,
211        ));
212        $file->lfh->z64Ext->assertValues(array(
213            "sizeField" => 28,
214            "size" => Count64::construct(0),
215            "sizeCompressed" => Count64::construct(0),
216            "diskNumberStart" => 0
217        ));
218      } else {
219        $file->lfh->assertValues(array(
220            "sizeCompressed" => 0,
221            "size" => 0,
222        ));
223      }
224      $file->lfh->assertValues(array(
225          "versionToExtract" => pack16le($this->getVersionToExtract($options['zip64'], File::DIR == $files[$filename]->type)),
226          "gpFlags" => (File::FILE == $files[$filename]->type ? GPFLAGS::ADD : GPFLAGS::NONE),
227          "gzMethod" => (File::FILE == $files[$filename]->type ? $options['compress'] : COMPR::STORE),
228          "dosTime" => pack32le(ZipStreamer::getDosTime($files[$filename]->date)),
229          "dataCRC32" => 0x0000,
230          "lengthFilename" => strlen($filename),
231          "filename" => $filename
232      ));
233
234      $endLastFile = $file->end;
235      $first = False;
236    }
237    if (0 < sizeof($files)) {
238      $this->assertEquals($offsetCD, $endLastFile + 1, "last file directly before CDH");
239    } else {
240      $this->assertEquals(0, $beginFollowingRecord, "empty zip file, CD records at beginning of file");
241    }
242  }
243
244  /**
245   * @return array array(filename, mimetype), expectedMimetype, expectedFilename, $description, $browser
246   */
247  public function providerSendHeadersOK() {
248    return array(
249      // Regular browsers
250        array(
251            array(),
252            'application/zip',
253            'archive.zip',
254            'default headers',
255            'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36',
256            'Content-Disposition: attachment; filename*=UTF-8\'\'archive.zip; filename="archive.zip"',
257        ),
258        array(
259            array(
260                'file.zip',
261                'application/octet-stream',
262                ),
263            'application/octet-stream',
264            'file.zip',
265            'specific headers',
266            'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36',
267            'Content-Disposition: attachment; filename*=UTF-8\'\'file.zip; filename="file.zip"',
268        ),
269      // Internet Explorer
270        array(
271            array(),
272            'application/zip',
273            'archive.zip',
274            'default headers',
275            'Mozilla/5.0 (compatible, MSIE 11, Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko',
276            'Content-Disposition: attachment; filename="archive.zip"',
277        ),
278        array(
279            array(
280                'file.zip',
281                'application/octet-stream',
282            ),
283            'application/octet-stream',
284            'file.zip',
285            'specific headers',
286            'Mozilla/5.0 (compatible, MSIE 11, Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko',
287            'Content-Disposition: attachment; filename="file.zip"',
288        ),
289    );
290  }
291
292  /**
293   * @dataProvider providerSendHeadersOK
294   * @preserveGlobalState disabled
295   * @runInSeparateProcess
296   *
297   * @param array $arguments
298   * @param string $expectedMimetype
299   * @param string $expectedFilename
300   * @param string $description
301   * @param string $browser
302   * @param string $expectedDisposition
303   */
304  public function testSendHeadersOKWithRegularBrowser(array $arguments,
305                                                      $expectedMimetype,
306                                                      $expectedFilename,
307                                                      $description,
308                                                      $browser,
309                                                      $expectedDisposition) {
310    $zip = new ZipStreamer(array(
311        'outstream' => $this->outstream
312    ));
313    $zip->turnOffOutputBuffering = false;
314    $_SERVER['HTTP_USER_AGENT'] = $browser;
315    call_user_func_array(array($zip, "sendHeaders"), $arguments);
316    $headers = xdebug_get_headers();
317    $this->assertContains('Pragma: public', $headers);
318    $this->assertContains('Expires: 0', $headers);
319    $this->assertContains('Accept-Ranges: bytes', $headers);
320    $this->assertContains('Connection: Keep-Alive', $headers);
321    $this->assertContains('Content-Transfer-Encoding: binary', $headers);
322    $this->assertContains('Content-Type: ' . $expectedMimetype, $headers);
323    $this->assertContains($expectedDisposition, $headers);
324    $this->assertContainsOneMatch('/^Last-Modified: /', $headers);
325  }
326
327  public function providerZipfileOK() {
328    $zip64Options = array(array(True, 'True'), array(False, 'False'));
329    $defaultLevelOption = array(array(COMPR::NORMAL, 'COMPR::NORMAL'));
330    $compressOptions = array(array(COMPR::STORE, 'COMPR::STORE'), array(COMPR::DEFLATE, 'COMPR::DEFLATE'));
331    $levelOptions = array(array(COMPR::NONE, 'COMPR::NONE'), array(COMPR::SUPERFAST, 'COMPR::SUPERFAST'), array(COMPR::MAXIMUM, 'COMPR::MAXIMUM'));
332    $fileSets = array(
333      array(
334        array(),
335        "empty"
336      ),
337      array(
338        array(
339          new File('test/', File::DIR, 1)
340        ),
341        "one empty dir"
342      ),
343      array(
344        array(
345          new File('test1.txt', File::FILE, 1, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit diam, posuere vel aliquet et, malesuada quis purus. Aliquam mattis aliquet massa, a semper sem porta in. Aliquam consectetur ligula a nulla vestibulum dictum. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam luctus faucibus urna, accumsan cursus neque laoreet eu. Suspendisse potenti. Nulla ut feugiat neque. Maecenas molestie felis non purus tempor, in blandit ligula tincidunt. Ut in tortor sit amet nisi rutrum vestibulum vel quis tortor. Sed bibendum mauris sit amet gravida tristique. Ut hendrerit sapien vel tellus dapibus, eu pharetra nulla adipiscing. Donec in quam faucibus, cursus lacus sed, elementum ligula. Morbi volutpat vel lacus malesuada condimentum. Fusce consectetur nisl euismod justo volutpat sodales.')
346        ),
347        "one file"
348      ),
349      array(
350        array(
351          new File('test1.txt', File::FILE, 1, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed elit diam, posuere vel aliquet et, malesuada quis purus. Aliquam mattis aliquet massa, a semper sem porta in. Aliquam consectetur ligula a nulla vestibulum dictum. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam luctus faucibus urna, accumsan cursus neque laoreet eu. Suspendisse potenti. Nulla ut feugiat neque. Maecenas molestie felis non purus tempor, in blandit ligula tincidunt. Ut in tortor sit amet nisi rutrum vestibulum vel quis tortor. Sed bibendum mauris sit amet gravida tristique. Ut hendrerit sapien vel tellus dapibus, eu pharetra nulla adipiscing. Donec in quam faucibus, cursus lacus sed, elementum ligula. Morbi volutpat vel lacus malesuada condimentum. Fusce consectetur nisl euismod justo volutpat sodales.'),
352          new File('test/', File::DIR, 1),
353          new File('test/test12.txt', File::FILE, 1, 'Duis malesuada lorem lorem, id sodales sapien sagittis ac. Donec in porttitor tellus, eu aliquam elit. Curabitur eu aliquam eros. Nulla accumsan augue quam, et consectetur quam eleifend eget. Donec cursus dolor lacus, eget pellentesque risus tincidunt at. Pellentesque rhoncus purus eget semper porta. Duis in magna tincidunt, fermentum orci non, consectetur nibh. Aliquam tortor eros, dignissim a posuere ac, rhoncus a justo. Sed sagittis velit ac massa pulvinar, ac pharetra ipsum fermentum. Etiam commodo lorem a scelerisque facilisis.')
354        ),
355        "simple structure"
356      )
357    );
358
359    $data = array();
360    foreach ($zip64Options as $zip64) {
361      foreach ($compressOptions as $compress) {
362        $levels = $defaultLevelOption;
363        if (COMPR::DEFLATE == $compress[0]) {
364          $levels = array_merge($levels, $levelOptions);
365        }
366        foreach ($levels as $level) {
367          foreach ($fileSets as $fileSet) {
368            $options = array(
369              'zip64' => $zip64[0],
370              'compress' => $compress[0],
371              'level' => $level[0]
372            );
373            $description = $fileSet[1] . ' (options = array(zip64=' . $zip64[1] . ', compress=' . $compress[1] . ', level=' . $level[1] . '))';
374            array_push($data, array(
375              $options,
376              $fileSet[0],
377              $description
378            ));
379          }
380        }
381      }
382    }
383    return $data;
384  }
385
386  /**
387   * @dataProvider providerZipfileOK
388   */
389  public function testZipfile($options, $files, $description) {
390    $options = array_merge($options, array('outstream' => $this->outstream));
391    $zip = new ZipStreamer($options);
392    foreach ($files as $file) {
393      if (File::DIR == $file->type) {
394        $zip->addEmptyDir($file->filename, array('timestamp' => $file->date));
395      } else {
396        $stream = fopen('php://memory', 'r+');
397        fwrite($stream, $file->data);
398        rewind($stream);
399        $zip->addFileFromStream($stream, $file->filename, array('timestamp' => $file->date));
400        fclose($stream);
401      }
402    }
403    $zip->finalize();
404
405    $this->assertOutputZipfileOK($files, $options);
406  }
407
408  /** https://github.com/McNetic/PHPZipStreamer/issues/29
409  *  ZipStreamer produces an error when the size of a file to be added is a
410  *   multiple of the STREAM_CHUNK_SIZE (also for empty files)
411  */
412  public function testIssue29() {
413    $options = array('zip64' => True,'compress' => COMPR::DEFLATE, 'outstream' => $this->outstream);
414    $zip = new ZipStreamer($options);
415    $stream = fopen('php://memory', 'r+');
416    $zip->addFileFromStream($stream, "test.bin");
417    fclose($stream);
418    $zip->finalize();
419  }
420}
421
422?>
423