435660ecd947169579927ed0d6c1652e74723169
[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/>.
18 /**
19  * Implementation of zip file archive.
20  *
21  * @package   core_files
22  * @copyright 2008 Petr Skoda (http://skodak.org)
23  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 defined('MOODLE_INTERNAL') || die();
28 require_once("$CFG->libdir/filestorage/file_archive.php");
30 /**
31  * zip file archive class.
32  *
33  * @package   core_files
34  * @category  files
35  * @copyright 2008 Petr Skoda (http://skodak.org)
36  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class zip_archive extends file_archive {
40     /** @var string Pathname of archive */
41     protected $archivepathname = null;
43     /** @var int archive open mode */
44     protected $mode = null;
46     /** @var int Used memory tracking */
47     protected $usedmem = 0;
49     /** @var int Iteration position */
50     protected $pos = 0;
52     /** @var ZipArchive instance */
53     protected $za;
55     /** @var bool was this archive modified? */
56     protected $modified = false;
58     /** @var array unicode decoding array, created by decoding zip file*/
59     protected $namelookup = null;
61     /**
62      * Create new zip_archive instance.
63      */
64     public function __construct() {
65         $this->encoding = null; // Autodetects encoding by default.
66     }
68     /**
69      * Open or create archive (depending on $mode)
70      *
71      * @todo MDL-31048 return error message
72      * @param string $archivepathname
73      * @param int $mode OPEN, CREATE or OVERWRITE constant
74      * @param string $encoding archive local paths encoding, empty means autodetect
75      * @return bool success
76      */
77     public function open($archivepathname, $mode=file_archive::CREATE, $encoding=null) {
78         $this->close();
80         $this->usedmem  = 0;
81         $this->pos      = 0;
82         $this->encoding = $encoding;
83         $this->mode     = $mode;
85         $this->za = new ZipArchive();
87         switch($mode) {
88             case file_archive::OPEN:      $flags = 0; break;
89             case file_archive::OVERWRITE: $flags = ZIPARCHIVE::CREATE | ZIPARCHIVE::OVERWRITE; break; //changed in PHP 5.2.8
90             case file_archive::CREATE:
91             default :                     $flags = ZIPARCHIVE::CREATE; break;
92         }
94         $result = $this->za->open($archivepathname, $flags);
96         if ($result === true) {
97             if (file_exists($archivepathname)) {
98                 $this->archivepathname = realpath($archivepathname);
99             } else {
100                 $this->archivepathname = $archivepathname;
101             }
102             return true;
104         } else {
105             $this->za = null;
106             $this->archivepathname = null;
107             // TODO: maybe we should return some error info
108             return false;
109         }
110     }
112     /**
113      * Normalize $localname, always keep in utf-8 encoding.
114      *
115      * @param string $localname name of file in utf-8 encoding
116      * @return string normalised compressed file or directory name
117      */
118     protected function mangle_pathname($localname) {
119         $result = str_replace('\\', '/', $localname);   // no MS \ separators
120         $result = preg_replace('/\.\.+/', '', $result); // prevent /.../
121         $result = ltrim($result, '/');                  // no leading slash
123         if ($result === '.') {
124             $result = '';
125         }
127         return $result;
128     }
130     /**
131      * Tries to convert $localname into utf-8
132      * please note that it may fail really badly.
133      * The resulting file name is cleaned.
134      *
135      * @param string $localname name (encoding is read from zip file or guessed)
136      * @return string in utf-8
137      */
138     protected function unmangle_pathname($localname) {
139         $this->init_namelookup();
141         if (!isset($this->namelookup[$localname])) {
142             $name = $localname;
143             // This should not happen
144             if (!empty($this->encoding) and $this->encoding !== 'utf-8') {
145                 $name = @textlib::convert($name, $this->encoding, 'utf-8');
146             }
147             $name = str_replace('\\', '/', $name);   // no MS \ separators
148             $name = clean_param($name, PARAM_PATH);  // only safe chars
149             return ltrim($name, '/');                // no leading slash
150         }
152         return $this->namelookup[$localname];
153     }
155     /**
156      * Close archive
157      *
158      * @return bool success
159      */
160     public function close() {
161         if (!isset($this->za)) {
162             return false;
163         }
165         $res = $this->za->close();
166         $this->za = null;
167         $this->mode = null;
168         $this->namelookup = null;
170         if ($this->modified) {
171             $this->fix_utf8_flags();
172             $this->modified = false;
173         }
175         return $res;
176     }
178     /**
179      * Returns file stream for reading of content
180      *
181      * @param int $index index of file
182      * @return resource|bool file handle or false if error
183      */
184     public function get_stream($index) {
185         if (!isset($this->za)) {
186             return false;
187         }
189         $name = $this->za->getNameIndex($index);
190         if ($name === false) {
191             return false;
192         }
194         return $this->za->getStream($name);
195     }
197     /**
198      * Returns file information
199      *
200      * @param int $index index of file
201      * @return stdClass|bool info object or false if error
202      */
203     public function get_info($index) {
204         if (!isset($this->za)) {
205             return false;
206         }
208         if ($index < 0 or $index >=$this->count()) {
209             return false;
210         }
212         $result = $this->za->statIndex($index);
214         if ($result === false) {
215             return false;
216         }
218         $info = new stdClass();
219         $info->index             = $index;
220         $info->original_pathname = $result['name'];
221         $info->pathname          = $this->unmangle_pathname($result['name']);
222         $info->mtime             = (int)$result['mtime'];
224         if ($info->pathname[strlen($info->pathname)-1] === '/') {
225             $info->is_directory = true;
226             $info->size         = 0;
227         } else {
228             $info->is_directory = false;
229             $info->size         = (int)$result['size'];
230         }
232         return $info;
233     }
235     /**
236      * Returns array of info about all files in archive
237      *
238      * @return array of file infos
239      */
240     public function list_files() {
241         if (!isset($this->za)) {
242             return false;
243         }
245         $infos = array();
247         for ($i=0; $i<$this->count(); $i++) {
248             $info = $this->get_info($i);
249             if ($info === false) {
250                 continue;
251             }
252             $infos[$i] = $info;
253         }
255         return $infos;
256     }
258     /**
259      * Returns number of files in archive
260      *
261      * @return int number of files
262      */
263     public function count() {
264         if (!isset($this->za)) {
265             return false;
266         }
268         return $this->za->numFiles;
269     }
271     /**
272      * Add file into archive
273      *
274      * @param string $localname name of file in archive
275      * @param string $pathname location of file
276      * @return bool success
277      */
278     public function add_file_from_pathname($localname, $pathname) {
279         if (!isset($this->za)) {
280             return false;
281         }
283         if ($this->archivepathname === realpath($pathname)) {
284             // do not add self into archive
285             return false;
286         }
288         if (is_null($localname)) {
289             $localname = clean_param($pathname, PARAM_PATH);
290         }
291         $localname = trim($localname, '/'); // no leading slashes in archives
292         $localname = $this->mangle_pathname($localname);
294         if ($localname === '') {
295             //sorry - conversion failed badly
296             return false;
297         }
299         if (!check_php_version('5.2.8')) {
300             // workaround for open file handles problem, ZipArchive uses file locking in order to prevent file modifications before the close() (strange, eh?)
301             if ($this->count() > 0 and $this->count() % 500 === 0) {
302                 $this->close();
303                 $res = $this->open($this->archivepathname, file_archive::OPEN, $this->encoding);
304                 if ($res !== true) {
305                     print_error('cannotopenzip');
306                 }
307             }
308         }
310         if (!$this->za->addFile($pathname, $localname)) {
311             return false;
312         }
313         $this->modified = true;
314         return true;
315     }
317     /**
318      * Add content of string into archive
319      *
320      * @param string $localname name of file in archive
321      * @param string $contents contents
322      * @return bool success
323      */
324     public function add_file_from_string($localname, $contents) {
325         if (!isset($this->za)) {
326             return false;
327         }
329         $localname = trim($localname, '/'); // no leading slashes in archives
330         $localname = $this->mangle_pathname($localname);
332         if ($localname === '') {
333             //sorry - conversion failed badly
334             return false;
335         }
337         if ($this->usedmem > 2097151) {
338             // this prevents running out of memory when adding many large files using strings
339             $this->close();
340             $res = $this->open($this->archivepathname, file_archive::OPEN, $this->encoding);
341             if ($res !== true) {
342                 print_error('cannotopenzip');
343             }
344         }
345         $this->usedmem += strlen($contents);
347         if (!$this->za->addFromString($localname, $contents)) {
348             return false;
349         }
350         $this->modified = true;
351         return true;
352     }
354     /**
355      * Add empty directory into archive
356      *
357      * @param string $localname name of file in archive
358      * @return bool success
359      */
360     public function add_directory($localname) {
361         if (!isset($this->za)) {
362             return false;
363         }
364         $localname = trim($localname, '/'). '/';
365         $localname = $this->mangle_pathname($localname);
367         if ($localname === '/') {
368             //sorry - conversion failed badly
369             return false;
370         }
372         if ($localname !== '') {
373             if (!$this->za->addEmptyDir($localname)) {
374                 return false;
375             }
376             $this->modified = true;
377         }
378         return true;
379     }
381     /**
382      * Returns current file info
383      *
384      * @return stdClass
385      */
386     public function current() {
387         if (!isset($this->za)) {
388             return false;
389         }
391         return $this->get_info($this->pos);
392     }
394     /**
395      * Returns the index of current file
396      *
397      * @return int current file index
398      */
399     public function key() {
400         return $this->pos;
401     }
403     /**
404      * Moves forward to next file
405      */
406     public function next() {
407         $this->pos++;
408     }
410     /**
411      * Rewinds back to the first file
412      */
413     public function rewind() {
414         $this->pos = 0;
415     }
417     /**
418      * Did we reach the end?
419      *
420      * @return bool
421      */
422     public function valid() {
423         if (!isset($this->za)) {
424             return false;
425         }
427         return ($this->pos < $this->count());
428     }
430     /**
431      * Create a map of file names used in zip archive.
432      * @return void
433      */
434     protected function init_namelookup() {
435         if (!isset($this->za)) {
436             return;
437         }
438         if (isset($this->namelookup)) {
439             return;
440         }
442         $this->namelookup = array();
444         if ($this->mode != file_archive::OPEN) {
445             // No need to tweak existing names when creating zip file because there are none yet!
446             return;
447         }
449         if (!file_exists($this->archivepathname)) {
450             return;
451         }
453         if (!$fp = fopen($this->archivepathname, 'rb')) {
454             return;
455         }
456         if (!$filesize = filesize($this->archivepathname)) {
457             return;
458         }
460         $centralend = self::zip_get_central_end($fp, $filesize);
462         if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
463             // Single disk archives only and o support for ZIP64, sorry.
464             fclose($fp);
465             return;
466         }
468         fseek($fp, $centralend['offset']);
469         $data = fread($fp, $centralend['size']);
470         $pos = 0;
471         $files = array();
472         for($i=0; $i<$centralend['entries']; $i++) {
473             $file = self::zip_parse_file_header($data, $centralend, $pos);
474             if ($file === false) {
475                 // Wrong header, sorry.
476                 fclose($fp);
477                 return;
478             }
479             $files[] = $file;
480         }
481         fclose($fp);
483         foreach ($files as $file) {
484             $name = $file['name'];
485             if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
486                 // No need to fix ASCII.
487                 $name = fix_utf8($name);
489             } else if (!($file['general'] & pow(2, 11))) {
490                 // First look for unicode name alternatives.
491                 $found = false;
492                 foreach($file['extra'] as $extra) {
493                     if ($extra['id'] === 0x7075) {
494                         $data = unpack('cversion/Vcrc', substr($extra['data'], 0, 5));
495                         if ($data['crc'] === crc32($name)) {
496                             $found = true;
497                             $name = substr($extra['data'], 5);
498                         }
499                     }
500                 }
501                 if (!$found and !empty($this->encoding) and $this->encoding !== 'utf-8') {
502                     // Try the encoding from open().
503                     $newname = @textlib::convert($name, $this->encoding, 'utf-8');
504                     $original  = textlib::convert($newname, 'utf-8', $this->encoding);
505                     if ($original === $name) {
506                         $found = true;
507                         $name = $newname;
508                     }
509                 }
510                 if (!$found and $file['version'] === 0x315) {
511                     // This looks like OS X build in zipper.
512                     $newname = fix_utf8($name);
513                     if ($newname === $name) {
514                         $found = true;
515                         $name = $newname;
516                     }
517                 }
518                 if (!$found and $file['version'] === 0) {
519                     // This looks like our old borked Moodle 2.2 file.
520                     $newname = fix_utf8($name);
521                     if ($newname === $name) {
522                         $found = true;
523                         $name = $newname;
524                     }
525                 }
526                 if (!$found and $encoding = get_string('oldcharset', 'langconfig')) {
527                     // Last attempt - try the dos/unix encoding from current language.
528                     $windows = true;
529                     foreach($file['extra'] as $extra) {
530                         // In Windows archivers do not usually set any extras with the exception of NTFS flag in WinZip/WinRar.
531                         $windows = false;
532                         if ($extra['id'] === 0x000a) {
533                             $windows = true;
534                             break;
535                         }
536                     }
538                     if ($windows === true) {
539                         switch(strtoupper($encoding)) {
540                             case 'ISO-8859-1': $encoding = 'CP850'; break;
541                             case 'ISO-8859-2': $encoding = 'CP852'; break;
542                             case 'ISO-8859-4': $encoding = 'CP775'; break;
543                             case 'ISO-8859-5': $encoding = 'CP866'; break;
544                             case 'ISO-8859-6': $encoding = 'CP720'; break;
545                             case 'ISO-8859-7': $encoding = 'CP737'; break;
546                             case 'ISO-8859-8': $encoding = 'CP862'; break;
547                         }
548                     }
549                     $newname = @textlib::convert($name, $encoding, 'utf-8');
550                     $original  = textlib::convert($newname, 'utf-8', $encoding);
552                     if ($original === $name) {
553                         $name = $newname;
554                     }
555                 }
556             }
557             $name = str_replace('\\', '/', $name);  // no MS \ separators
558             $name = clean_param($name, PARAM_PATH); // only safe chars
559             $name = ltrim($name, '/');              // no leading slash
561             if (function_exists('normalizer_normalize')) {
562                 $name = normalizer_normalize($name, Normalizer::FORM_C);
563             }
565             $this->namelookup[$file['name']] = $name;
566         }
567     }
569     /**
570      * Add unicode flag to all files in archive.
571      *
572      * NOTE: single disk archives only, no ZIP64 support.
573      *
574      * @return bool success, modifies the file contents
575      */
576     protected function fix_utf8_flags() {
577         if (!file_exists($this->archivepathname)) {
578             return true;
579         }
581         // Note: the ZIP structure is described at http://www.pkware.com/documents/casestudies/APPNOTE.TXT
582         if (!$fp = fopen($this->archivepathname, 'rb+')) {
583             return false;
584         }
585         if (!$filesize = filesize($this->archivepathname)) {
586             return false;
587         }
589         $centralend = self::zip_get_central_end($fp, $filesize);
591         if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
592             // Single disk archives only and o support for ZIP64, sorry.
593             fclose($fp);
594             return false;
595         }
597         fseek($fp, $centralend['offset']);
598         $data = fread($fp, $centralend['size']);
599         $pos = 0;
600         $files = array();
601         for($i=0; $i<$centralend['entries']; $i++) {
602             $file = self::zip_parse_file_header($data, $centralend, $pos);
603             if ($file === false) {
604                 // Wrong header, sorry.
605                 fclose($fp);
606                 return false;
607             }
609             $newgeneral = $file['general'] | pow(2, 11);
610             if ($newgeneral === $file['general']) {
611                 // Nothing to do with this file.
612                 continue;
613             }
615             if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
616                 // ASCII file names are always ok.
617                 continue;
618             }
619             if ($file['extra']) {
620                 // Most probably not created by php zip ext, better to skip it.
621                 continue;
622             }
623             if (fix_utf8($file['name']) !== $file['name']) {
624                 // Does not look like a valid utf-8 encoded file name, skip it.
625                 continue;
626             }
628             // Read local file header.
629             fseek($fp, $file['local_offset']);
630             $localfile = unpack('Vsig/vversion_req/vgeneral/vmethod/vmtime/vmdate/Vcrc/Vsize_compressed/Vsize/vname_length/vextra_length', fread($fp, 30));
631             if ($localfile['sig'] !== 0x04034b50) {
632                 // Borked file!
633                 fclose($fp);
634                 return false;
635             }
637             $file['local'] = $localfile;
638             $files[] = $file;
639         }
641         foreach ($files as $file) {
642             $localfile = $file['local'];
643             // Add the unicode flag in central file header.
644             fseek($fp, $file['central_offset'] + 8);
645             if (ftell($fp) === $file['central_offset'] + 8) {
646                 $newgeneral = $file['general'] | pow(2, 11);
647                 fwrite($fp, pack('v', $newgeneral));
648             }
649             // Modify local file header too.
650             fseek($fp, $file['local_offset'] + 6);
651             if (ftell($fp) === $file['local_offset'] + 6) {
652                 $newgeneral = $localfile['general'] | pow(2, 11);
653                 fwrite($fp, pack('v', $newgeneral));
654             }
655         }
657         fclose($fp);
658         return true;
659     }
661     /**
662      * Read end of central signature of ZIP file.
663      * @internal
664      * @static
665      * @param resource $fp
666      * @param int $filesize
667      * @return array|bool
668      */
669     public static function zip_get_central_end($fp, $filesize) {
670         // Find end of central directory record.
671         fseek($fp, $filesize - 22);
672         $info = unpack('Vsig', fread($fp, 4));
673         if ($info['sig'] === 0x06054b50) {
674             // There is no comment.
675             fseek($fp, $filesize - 22);
676             $data = fread($fp, 22);
677         } else {
678             // There is some comment with 0xFF max size - that is 65557.
679             fseek($fp, $filesize - 65557);
680             $data = fread($fp, 65557);
681         }
683         $pos = strpos($data, pack('V', 0x06054b50));
684         if ($pos === false) {
685             // Borked ZIP structure!
686             return false;
687         }
688         $centralend = unpack('Vsig/vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_length', substr($data, $pos, 22));
689         if ($centralend['comment_length']) {
690             $centralend['comment'] = substr($data, 22, $centralend['comment_length']);
691         } else {
692             $centralend['comment'] = '';
693         }
695         return $centralend;
696     }
698     /**
699      * Parse file header
700      * @internal
701      * @param string $data
702      * @param array $centralend
703      * @param int $pos (modified)
704      * @return array|bool file info
705      */
706     public static function zip_parse_file_header($data, $centralend, &$pos) {
707         $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));
708         $file['central_offset'] = $centralend['offset'] + $pos;
709         $pos = $pos + 46;
710         if ($file['sig'] !== 0x02014b50) {
711             // Borked ZIP structure!
712             return false;
713         }
714         $file['name'] = substr($data, $pos, $file['name_length']);
715         $pos = $pos + $file['name_length'];
716         $file['extra'] = array();
717         $file['extra_data'] = '';
718         if ($file['extra_length']) {
719             $extradata = substr($data, $pos, $file['extra_length']);
720             $file['extra_data'] = $extradata;
721             while (strlen($extradata) > 4) {
722                 $extra = unpack('vid/vsize', substr($extradata, 0, 4));
723                 $extra['data'] = substr($extradata, 4, $extra['size']);
724                 $extradata = substr($extradata, 4+$extra['size']);
725                 $file['extra'][] = $extra;
726             }
727             $pos = $pos + $file['extra_length'];
728         }
729         if ($file['comment_length']) {
730             $pos = $pos + $file['comment_length'];
731             $file['comment'] = substr($data, $pos, $file['comment_length']);
732         } else {
733             $file['comment'] = '';
734         }
735         return $file;
736     }