MDL-37429 zipping improvements
[moodle.git] / lib / filestorage / zip_archive.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Implementation of zip file archive.
19  *
20  * @package   core_files
21  * @copyright 2008 Petr Skoda (http://skodak.org)
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 require_once("$CFG->libdir/filestorage/file_archive.php");
29 /**
30  * Zip file archive class.
31  *
32  * @package   core_files
33  * @category  files
34  * @copyright 2008 Petr Skoda (http://skodak.org)
35  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
37 class zip_archive extends file_archive {
39     /** @var string Pathname of archive */
40     protected $archivepathname = null;
42     /** @var int archive open mode */
43     protected $mode = null;
45     /** @var int Used memory tracking */
46     protected $usedmem = 0;
48     /** @var int Iteration position */
49     protected $pos = 0;
51     /** @var ZipArchive instance */
52     protected $za;
54     /** @var bool was this archive modified? */
55     protected $modified = false;
57     /** @var array unicode decoding array, created by decoding zip file */
58     protected $namelookup = null;
60     /**
61      * Create new zip_archive instance.
62      */
63     public function __construct() {
64         $this->encoding = null; // Autodetects encoding by default.
65     }
67     /**
68      * Open or create archive (depending on $mode).
69      *
70      * @todo MDL-31048 return error message
71      * @param string $archivepathname
72      * @param int $mode OPEN, CREATE or OVERWRITE constant
73      * @param string $encoding archive local paths encoding, empty means autodetect
74      * @return bool success
75      */
76     public function open($archivepathname, $mode=file_archive::CREATE, $encoding=null) {
77         $this->close();
79         $this->usedmem  = 0;
80         $this->pos      = 0;
81         $this->encoding = $encoding;
82         $this->mode     = $mode;
84         $this->za = new ZipArchive();
86         switch($mode) {
87             case file_archive::OPEN:      $flags = 0; break;
88             case file_archive::OVERWRITE: $flags = ZIPARCHIVE::CREATE | ZIPARCHIVE::OVERWRITE; break; //changed in PHP 5.2.8
89             case file_archive::CREATE:
90             default :                     $flags = ZIPARCHIVE::CREATE; break;
91         }
93         $result = $this->za->open($archivepathname, $flags);
95         if ($result === true) {
96             if (file_exists($archivepathname)) {
97                 $this->archivepathname = realpath($archivepathname);
98             } else {
99                 $this->archivepathname = $archivepathname;
100             }
101             return true;
103         } else {
104             $message = 'Unknown error.';
105             switch ($result) {
106                 case ZIPARCHIVE::ER_EXISTS: $message = 'File already exists.'; break;
107                 case ZIPARCHIVE::ER_INCONS: $message = 'Zip archive inconsistent.'; break;
108                 case ZIPARCHIVE::ER_INVAL: $message = 'Invalid argument.'; break;
109                 case ZIPARCHIVE::ER_MEMORY: $message = 'Malloc failure.'; break;
110                 case ZIPARCHIVE::ER_NOENT: $message = 'No such file.'; break;
111                 case ZIPARCHIVE::ER_NOZIP: $message = 'Not a zip archive.'; break;
112                 case ZIPARCHIVE::ER_OPEN: $message = 'Can\'t open file.'; break;
113                 case ZIPARCHIVE::ER_READ: $message = 'Read error.'; break;
114                 case ZIPARCHIVE::ER_SEEK: $message = 'Seek error.'; break;
115             }
116             debugging($message.': '.$archivepathname, DEBUG_DEVELOPER);
117             $this->za = null;
118             $this->archivepathname = null;
119             return false;
120         }
121     }
123     /**
124      * Normalize $localname, always keep in utf-8 encoding.
125      *
126      * @param string $localname name of file in utf-8 encoding
127      * @return string normalised compressed file or directory name
128      */
129     protected function mangle_pathname($localname) {
130         $result = str_replace('\\', '/', $localname);   // no MS \ separators
131         $result = preg_replace('/\.\.+/', '', $result); // prevent /.../
132         $result = ltrim($result, '/');                  // no leading slash
134         if ($result === '.') {
135             $result = '';
136         }
138         return $result;
139     }
141     /**
142      * Tries to convert $localname into utf-8
143      * please note that it may fail really badly.
144      * The resulting file name is cleaned.
145      *
146      * @param string $localname name (encoding is read from zip file or guessed)
147      * @return string in utf-8
148      */
149     protected function unmangle_pathname($localname) {
150         $this->init_namelookup();
152         if (!isset($this->namelookup[$localname])) {
153             $name = $localname;
154             // This should not happen.
155             if (!empty($this->encoding) and $this->encoding !== 'utf-8') {
156                 $name = @textlib::convert($name, $this->encoding, 'utf-8');
157             }
158             $name = str_replace('\\', '/', $name);   // no MS \ separators
159             $name = clean_param($name, PARAM_PATH);  // only safe chars
160             return ltrim($name, '/');                // no leading slash
161         }
163         return $this->namelookup[$localname];
164     }
166     /**
167      * Close archive, write changes to disk.
168      *
169      * @return bool success
170      */
171     public function close() {
172         if (!isset($this->za)) {
173             return false;
174         }
176         if ($this->za->numFiles == 0) {
177             // PHP can not create empty archives, so let's fake it.
178             $this->za->close();
179             $this->za = null;
180             $this->mode = null;
181             $this->namelookup = null;
182             $this->modified = false;
183             @unlink($this->archivepathname);
184             $data = base64_decode('UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==');
185             if (!file_put_contents($this->archivepathname, $data)) {
186                 return false;
187             }
188             return true;
189         }
191         $res = $this->za->close();
192         $this->za = null;
193         $this->mode = null;
194         $this->namelookup = null;
196         if ($this->modified) {
197             $this->fix_utf8_flags();
198             $this->modified = false;
199         }
201         return $res;
202     }
204     /**
205      * Returns file stream for reading of content.
206      *
207      * @param int $index index of file
208      * @return resource|bool file handle or false if error
209      */
210     public function get_stream($index) {
211         if (!isset($this->za)) {
212             return false;
213         }
215         $name = $this->za->getNameIndex($index);
216         if ($name === false) {
217             return false;
218         }
220         return $this->za->getStream($name);
221     }
223     /**
224      * Returns file information.
225      *
226      * @param int $index index of file
227      * @return stdClass|bool info object or false if error
228      */
229     public function get_info($index) {
230         if (!isset($this->za)) {
231             return false;
232         }
234         if ($index < 0 or $index >=$this->count()) {
235             return false;
236         }
238         $result = $this->za->statIndex($index);
240         if ($result === false) {
241             return false;
242         }
244         $info = new stdClass();
245         $info->index             = $index;
246         $info->original_pathname = $result['name'];
247         $info->pathname          = $this->unmangle_pathname($result['name']);
248         $info->mtime             = (int)$result['mtime'];
250         if ($info->pathname[strlen($info->pathname)-1] === '/') {
251             $info->is_directory = true;
252             $info->size         = 0;
253         } else {
254             $info->is_directory = false;
255             $info->size         = (int)$result['size'];
256         }
258         return $info;
259     }
261     /**
262      * Returns array of info about all files in archive.
263      *
264      * @return array of file infos
265      */
266     public function list_files() {
267         if (!isset($this->za)) {
268             return false;
269         }
271         $infos = array();
273         for ($i=0; $i<$this->count(); $i++) {
274             $info = $this->get_info($i);
275             if ($info === false) {
276                 continue;
277             }
278             $infos[$i] = $info;
279         }
281         return $infos;
282     }
284     /**
285      * Returns number of files in archive.
286      *
287      * @return int number of files
288      */
289     public function count() {
290         if (!isset($this->za)) {
291             return false;
292         }
294         return $this->za->numFiles;
295     }
297     /**
298      * Add file into archive.
299      *
300      * @param string $localname name of file in archive
301      * @param string $pathname location of file
302      * @return bool success
303      */
304     public function add_file_from_pathname($localname, $pathname) {
305         if (!isset($this->za)) {
306             return false;
307         }
309         if ($this->archivepathname === realpath($pathname)) {
310             // Do not add self into archive.
311             return false;
312         }
314         if (!is_readable($pathname) or is_dir($pathname)) {
315             return false;
316         }
318         if (is_null($localname)) {
319             $localname = clean_param($pathname, PARAM_PATH);
320         }
321         $localname = trim($localname, '/'); // No leading slashes in archives!
322         $localname = $this->mangle_pathname($localname);
324         if ($localname === '') {
325             // Sorry - conversion failed badly.
326             return false;
327         }
329         if (!$this->za->addFile($pathname, $localname)) {
330             return false;
331         }
332         $this->modified = true;
333         return true;
334     }
336     /**
337      * Add content of string into archive.
338      *
339      * @param string $localname name of file in archive
340      * @param string $contents contents
341      * @return bool success
342      */
343     public function add_file_from_string($localname, $contents) {
344         if (!isset($this->za)) {
345             return false;
346         }
348         $localname = trim($localname, '/'); // No leading slashes in archives!
349         $localname = $this->mangle_pathname($localname);
351         if ($localname === '') {
352             // Sorry - conversion failed badly.
353             return false;
354         }
356         if ($this->usedmem > 2097151) {
357             // This prevents running out of memory when adding many large files using strings.
358             $this->close();
359             $res = $this->open($this->archivepathname, file_archive::OPEN, $this->encoding);
360             if ($res !== true) {
361                 print_error('cannotopenzip');
362             }
363         }
364         $this->usedmem += strlen($contents);
366         if (!$this->za->addFromString($localname, $contents)) {
367             return false;
368         }
369         $this->modified = true;
370         return true;
371     }
373     /**
374      * Add empty directory into archive.
375      *
376      * @param string $localname name of file in archive
377      * @return bool success
378      */
379     public function add_directory($localname) {
380         if (!isset($this->za)) {
381             return false;
382         }
383         $localname = trim($localname, '/'). '/';
384         $localname = $this->mangle_pathname($localname);
386         if ($localname === '/') {
387             // Sorry - conversion failed badly.
388             return false;
389         }
391         if ($localname !== '') {
392             if (!$this->za->addEmptyDir($localname)) {
393                 return false;
394             }
395             $this->modified = true;
396         }
397         return true;
398     }
400     /**
401      * Returns current file info.
402      *
403      * @return stdClass
404      */
405     public function current() {
406         if (!isset($this->za)) {
407             return false;
408         }
410         return $this->get_info($this->pos);
411     }
413     /**
414      * Returns the index of current file.
415      *
416      * @return int current file index
417      */
418     public function key() {
419         return $this->pos;
420     }
422     /**
423      * Moves forward to next file.
424      */
425     public function next() {
426         $this->pos++;
427     }
429     /**
430      * Rewinds back to the first file.
431      */
432     public function rewind() {
433         $this->pos = 0;
434     }
436     /**
437      * Did we reach the end?
438      *
439      * @return bool
440      */
441     public function valid() {
442         if (!isset($this->za)) {
443             return false;
444         }
446         return ($this->pos < $this->count());
447     }
449     /**
450      * Create a map of file names used in zip archive.
451      * @return void
452      */
453     protected function init_namelookup() {
454         if (!isset($this->za)) {
455             return;
456         }
457         if (isset($this->namelookup)) {
458             return;
459         }
461         $this->namelookup = array();
463         if ($this->mode != file_archive::OPEN) {
464             // No need to tweak existing names when creating zip file because there are none yet!
465             return;
466         }
468         if (!file_exists($this->archivepathname)) {
469             return;
470         }
472         if (!$fp = fopen($this->archivepathname, 'rb')) {
473             return;
474         }
475         if (!$filesize = filesize($this->archivepathname)) {
476             return;
477         }
479         $centralend = self::zip_get_central_end($fp, $filesize);
481         if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
482             // Single disk archives only and o support for ZIP64, sorry.
483             fclose($fp);
484             return;
485         }
487         fseek($fp, $centralend['offset']);
488         $data = fread($fp, $centralend['size']);
489         $pos = 0;
490         $files = array();
491         for($i=0; $i<$centralend['entries']; $i++) {
492             $file = self::zip_parse_file_header($data, $centralend, $pos);
493             if ($file === false) {
494                 // Wrong header, sorry.
495                 fclose($fp);
496                 return;
497             }
498             $files[] = $file;
499         }
500         fclose($fp);
502         foreach ($files as $file) {
503             $name = $file['name'];
504             if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
505                 // No need to fix ASCII.
506                 $name = fix_utf8($name);
508             } else if (!($file['general'] & pow(2, 11))) {
509                 // First look for unicode name alternatives.
510                 $found = false;
511                 foreach($file['extra'] as $extra) {
512                     if ($extra['id'] === 0x7075) {
513                         $data = unpack('cversion/Vcrc', substr($extra['data'], 0, 5));
514                         if ($data['crc'] === crc32($name)) {
515                             $found = true;
516                             $name = substr($extra['data'], 5);
517                         }
518                     }
519                 }
520                 if (!$found and !empty($this->encoding) and $this->encoding !== 'utf-8') {
521                     // Try the encoding from open().
522                     $newname = @textlib::convert($name, $this->encoding, 'utf-8');
523                     $original  = textlib::convert($newname, 'utf-8', $this->encoding);
524                     if ($original === $name) {
525                         $found = true;
526                         $name = $newname;
527                     }
528                 }
529                 if (!$found and $file['version'] === 0x315) {
530                     // This looks like OS X build in zipper.
531                     $newname = fix_utf8($name);
532                     if ($newname === $name) {
533                         $found = true;
534                         $name = $newname;
535                     }
536                 }
537                 if (!$found and $file['version'] === 0) {
538                     // This looks like our old borked Moodle 2.2 file.
539                     $newname = fix_utf8($name);
540                     if ($newname === $name) {
541                         $found = true;
542                         $name = $newname;
543                     }
544                 }
545                 if (!$found and $encoding = get_string('oldcharset', 'langconfig')) {
546                     // Last attempt - try the dos/unix encoding from current language.
547                     $windows = true;
548                     foreach($file['extra'] as $extra) {
549                         // In Windows archivers do not usually set any extras with the exception of NTFS flag in WinZip/WinRar.
550                         $windows = false;
551                         if ($extra['id'] === 0x000a) {
552                             $windows = true;
553                             break;
554                         }
555                     }
557                     if ($windows === true) {
558                         switch(strtoupper($encoding)) {
559                             case 'ISO-8859-1': $encoding = 'CP850'; break;
560                             case 'ISO-8859-2': $encoding = 'CP852'; break;
561                             case 'ISO-8859-4': $encoding = 'CP775'; break;
562                             case 'ISO-8859-5': $encoding = 'CP866'; break;
563                             case 'ISO-8859-6': $encoding = 'CP720'; break;
564                             case 'ISO-8859-7': $encoding = 'CP737'; break;
565                             case 'ISO-8859-8': $encoding = 'CP862'; break;
566                         }
567                     }
568                     $newname = @textlib::convert($name, $encoding, 'utf-8');
569                     $original  = textlib::convert($newname, 'utf-8', $encoding);
571                     if ($original === $name) {
572                         $name = $newname;
573                     }
574                 }
575             }
576             $name = str_replace('\\', '/', $name);  // no MS \ separators
577             $name = clean_param($name, PARAM_PATH); // only safe chars
578             $name = ltrim($name, '/');              // no leading slash
580             if (function_exists('normalizer_normalize')) {
581                 $name = normalizer_normalize($name, Normalizer::FORM_C);
582             }
584             $this->namelookup[$file['name']] = $name;
585         }
586     }
588     /**
589      * Add unicode flag to all files in archive.
590      *
591      * NOTE: single disk archives only, no ZIP64 support.
592      *
593      * @return bool success, modifies the file contents
594      */
595     protected function fix_utf8_flags() {
596         if (!file_exists($this->archivepathname)) {
597             return true;
598         }
600         // Note: the ZIP structure is described at http://www.pkware.com/documents/casestudies/APPNOTE.TXT
601         if (!$fp = fopen($this->archivepathname, 'rb+')) {
602             return false;
603         }
604         if (!$filesize = filesize($this->archivepathname)) {
605             return false;
606         }
608         $centralend = self::zip_get_central_end($fp, $filesize);
610         if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
611             // Single disk archives only and o support for ZIP64, sorry.
612             fclose($fp);
613             return false;
614         }
616         fseek($fp, $centralend['offset']);
617         $data = fread($fp, $centralend['size']);
618         $pos = 0;
619         $files = array();
620         for($i=0; $i<$centralend['entries']; $i++) {
621             $file = self::zip_parse_file_header($data, $centralend, $pos);
622             if ($file === false) {
623                 // Wrong header, sorry.
624                 fclose($fp);
625                 return false;
626             }
628             $newgeneral = $file['general'] | pow(2, 11);
629             if ($newgeneral === $file['general']) {
630                 // Nothing to do with this file.
631                 continue;
632             }
634             if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
635                 // ASCII file names are always ok.
636                 continue;
637             }
638             if ($file['extra']) {
639                 // Most probably not created by php zip ext, better to skip it.
640                 continue;
641             }
642             if (fix_utf8($file['name']) !== $file['name']) {
643                 // Does not look like a valid utf-8 encoded file name, skip it.
644                 continue;
645             }
647             // Read local file header.
648             fseek($fp, $file['local_offset']);
649             $localfile = unpack('Vsig/vversion_req/vgeneral/vmethod/vmtime/vmdate/Vcrc/Vsize_compressed/Vsize/vname_length/vextra_length', fread($fp, 30));
650             if ($localfile['sig'] !== 0x04034b50) {
651                 // Borked file!
652                 fclose($fp);
653                 return false;
654             }
656             $file['local'] = $localfile;
657             $files[] = $file;
658         }
660         foreach ($files as $file) {
661             $localfile = $file['local'];
662             // Add the unicode flag in central file header.
663             fseek($fp, $file['central_offset'] + 8);
664             if (ftell($fp) === $file['central_offset'] + 8) {
665                 $newgeneral = $file['general'] | pow(2, 11);
666                 fwrite($fp, pack('v', $newgeneral));
667             }
668             // Modify local file header too.
669             fseek($fp, $file['local_offset'] + 6);
670             if (ftell($fp) === $file['local_offset'] + 6) {
671                 $newgeneral = $localfile['general'] | pow(2, 11);
672                 fwrite($fp, pack('v', $newgeneral));
673             }
674         }
676         fclose($fp);
677         return true;
678     }
680     /**
681      * Read end of central signature of ZIP file.
682      * @internal
683      * @static
684      * @param resource $fp
685      * @param int $filesize
686      * @return array|bool
687      */
688     public static function zip_get_central_end($fp, $filesize) {
689         // Find end of central directory record.
690         fseek($fp, $filesize - 22);
691         $info = unpack('Vsig', fread($fp, 4));
692         if ($info['sig'] === 0x06054b50) {
693             // There is no comment.
694             fseek($fp, $filesize - 22);
695             $data = fread($fp, 22);
696         } else {
697             // There is some comment with 0xFF max size - that is 65557.
698             fseek($fp, $filesize - 65557);
699             $data = fread($fp, 65557);
700         }
702         $pos = strpos($data, pack('V', 0x06054b50));
703         if ($pos === false) {
704             // Borked ZIP structure!
705             return false;
706         }
707         $centralend = unpack('Vsig/vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_length', substr($data, $pos, 22));
708         if ($centralend['comment_length']) {
709             $centralend['comment'] = substr($data, 22, $centralend['comment_length']);
710         } else {
711             $centralend['comment'] = '';
712         }
714         return $centralend;
715     }
717     /**
718      * Parse file header.
719      * @internal
720      * @param string $data
721      * @param array $centralend
722      * @param int $pos (modified)
723      * @return array|bool file info
724      */
725     public static function zip_parse_file_header($data, $centralend, &$pos) {
726         $file = unpack('Vsig/vversion/vversion_req/vgeneral/vmethod/Vmodified/Vcrc/Vsize_compressed/Vsize/vname_length/vextra_length/vcomment_length/vdisk/vattr/Vattrext/Vlocal_offset', substr($data, $pos, 46));
727         $file['central_offset'] = $centralend['offset'] + $pos;
728         $pos = $pos + 46;
729         if ($file['sig'] !== 0x02014b50) {
730             // Borked ZIP structure!
731             return false;
732         }
733         $file['name'] = substr($data, $pos, $file['name_length']);
734         $pos = $pos + $file['name_length'];
735         $file['extra'] = array();
736         $file['extra_data'] = '';
737         if ($file['extra_length']) {
738             $extradata = substr($data, $pos, $file['extra_length']);
739             $file['extra_data'] = $extradata;
740             while (strlen($extradata) > 4) {
741                 $extra = unpack('vid/vsize', substr($extradata, 0, 4));
742                 $extra['data'] = substr($extradata, 4, $extra['size']);
743                 $extradata = substr($extradata, 4+$extra['size']);
744                 $file['extra'][] = $extra;
745             }
746             $pos = $pos + $file['extra_length'];
747         }
748         if ($file['comment_length']) {
749             $pos = $pos + $file['comment_length'];
750             $file['comment'] = substr($data, $pos, $file['comment_length']);
751         } else {
752             $file['comment'] = '';
753         }
754         return $file;
755     }