2 // This file is part of Moodle - http://moodle.org/
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.
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.
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 * Implementation of zip file archive.
21 * @copyright 2008 Petr Skoda (http://skodak.org)
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 defined('MOODLE_INTERNAL') ||
die();
27 require_once("$CFG->libdir/filestorage/file_archive.php");
30 * Zip file archive class.
34 * @copyright 2008 Petr Skoda (http://skodak.org)
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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 */
51 /** @var ZipArchive instance */
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 /** @var string base64 encoded contents of empty zip file */
61 protected static $emptyzipcontent = 'UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==';
63 /** @var bool ugly hack for broken empty zip handling in < PHP 5.3.10 */
64 protected $emptyziphack = false;
67 * Create new zip_archive instance.
69 public function __construct() {
70 $this->encoding
= null; // Autodetects encoding by default.
74 * Open or create archive (depending on $mode).
76 * @todo MDL-31048 return error message
77 * @param string $archivepathname
78 * @param int $mode OPEN, CREATE or OVERWRITE constant
79 * @param string $encoding archive local paths encoding, empty means autodetect
80 * @return bool success
82 public function open($archivepathname, $mode=file_archive
::CREATE
, $encoding=null) {
87 $this->encoding
= $encoding;
90 $this->za
= new ZipArchive();
93 case file_archive
::OPEN
: $flags = 0; break;
94 case file_archive
::OVERWRITE
: $flags = ZIPARCHIVE
::CREATE | ZIPARCHIVE
::OVERWRITE
; break; //changed in PHP 5.2.8
95 case file_archive
::CREATE
:
96 default : $flags = ZIPARCHIVE
::CREATE
; break;
99 $result = $this->za
->open($archivepathname, $flags);
101 if ($flags == 0 and $result === ZIPARCHIVE
::ER_NOZIP
and filesize($archivepathname) === 22) {
102 // Legacy PHP versions < 5.3.10 can not deal with empty zip archives.
103 if (file_get_contents($archivepathname) === base64_decode(self
::$emptyzipcontent)) {
104 if ($temp = make_temp_directory('zip')) {
105 $this->emptyziphack
= tempnam($temp, 'zip');
106 $this->za
= new ZipArchive();
107 $result = $this->za
->open($this->emptyziphack
, ZIPARCHIVE
::CREATE
);
112 if ($result === true) {
113 if (file_exists($archivepathname)) {
114 $this->archivepathname
= realpath($archivepathname);
116 $this->archivepathname
= $archivepathname;
121 $message = 'Unknown error.';
123 case ZIPARCHIVE
::ER_EXISTS
: $message = 'File already exists.'; break;
124 case ZIPARCHIVE
::ER_INCONS
: $message = 'Zip archive inconsistent.'; break;
125 case ZIPARCHIVE
::ER_INVAL
: $message = 'Invalid argument.'; break;
126 case ZIPARCHIVE
::ER_MEMORY
: $message = 'Malloc failure.'; break;
127 case ZIPARCHIVE
::ER_NOENT
: $message = 'No such file.'; break;
128 case ZIPARCHIVE
::ER_NOZIP
: $message = 'Not a zip archive.'; break;
129 case ZIPARCHIVE
::ER_OPEN
: $message = 'Can\'t open file.'; break;
130 case ZIPARCHIVE
::ER_READ
: $message = 'Read error.'; break;
131 case ZIPARCHIVE
::ER_SEEK
: $message = 'Seek error.'; break;
133 debugging($message.': '.$archivepathname, DEBUG_DEVELOPER
);
135 $this->archivepathname
= null;
141 * Normalize $localname, always keep in utf-8 encoding.
143 * @param string $localname name of file in utf-8 encoding
144 * @return string normalised compressed file or directory name
146 protected function mangle_pathname($localname) {
147 $result = str_replace('\\', '/', $localname); // no MS \ separators
148 $result = preg_replace('/\.\.+\//', '', $result); // Cleanup any potential ../ transversal (any number of dots).
149 $result = preg_replace('/\.\.+/', '.', $result); // Join together any number of consecutive dots.
150 $result = ltrim($result, '/'); // no leading slash
152 if ($result === '.') {
160 * Tries to convert $localname into utf-8
161 * please note that it may fail really badly.
162 * The resulting file name is cleaned.
164 * @param string $localname name (encoding is read from zip file or guessed)
165 * @return string in utf-8
167 protected function unmangle_pathname($localname) {
168 $this->init_namelookup();
170 if (!isset($this->namelookup
[$localname])) {
172 // This should not happen.
173 if (!empty($this->encoding
) and $this->encoding
!== 'utf-8') {
174 $name = @core_text
::convert($name, $this->encoding
, 'utf-8');
176 $name = str_replace('\\', '/', $name); // no MS \ separators
177 $name = clean_param($name, PARAM_PATH
); // only safe chars
178 return ltrim($name, '/'); // no leading slash
181 return $this->namelookup
[$localname];
185 * Close archive, write changes to disk.
187 * @return bool success
189 public function close() {
190 if (!isset($this->za
)) {
194 if ($this->emptyziphack
) {
198 $this->namelookup
= null;
199 $this->modified
= false;
200 @unlink
($this->emptyziphack
);
201 $this->emptyziphack
= false;
204 } else if ($this->za
->numFiles
== 0) {
205 // PHP can not create empty archives, so let's fake it.
209 $this->namelookup
= null;
210 $this->modified
= false;
211 // If the existing archive is already empty, we didn't change it. Don't bother completing a save.
212 // This is important when we are inspecting archives that we might not have write permission to.
213 if (@filesize
($this->archivepathname
) == 22 &&
214 @file_get_contents
($this->archivepathname
) === base64_decode(self
::$emptyzipcontent)) {
217 @unlink
($this->archivepathname
);
218 $data = base64_decode(self
::$emptyzipcontent);
219 if (!file_put_contents($this->archivepathname
, $data)) {
225 $res = $this->za
->close();
228 $this->namelookup
= null;
230 if ($this->modified
) {
231 $this->fix_utf8_flags();
232 $this->modified
= false;
239 * Returns file stream for reading of content.
241 * @param int $index index of file
242 * @return resource|bool file handle or false if error
244 public function get_stream($index) {
245 if (!isset($this->za
)) {
249 $name = $this->za
->getNameIndex($index);
250 if ($name === false) {
254 return $this->za
->getStream($name);
258 * Returns file information.
260 * @param int $index index of file
261 * @return stdClass|bool info object or false if error
263 public function get_info($index) {
264 if (!isset($this->za
)) {
268 // Need to use the ZipArchive's numfiles, as $this->count() relies on this function to count actual files (skipping OSX junk).
269 if ($index < 0 or $index >=$this->za
->numFiles
) {
273 // PHP 5.6 introduced encoding guessing logic, we need to fall back
274 // to raw ZIP_FL_ENC_RAW (== 64) to get consistent results as in PHP 5.5.
275 $result = $this->za
->statIndex($index, 64);
277 if ($result === false) {
281 $info = new stdClass();
282 $info->index
= $index;
283 $info->original_pathname
= $result['name'];
284 $info->pathname
= $this->unmangle_pathname($result['name']);
285 $info->mtime
= (int)$result['mtime'];
287 if ($info->pathname
[strlen($info->pathname
)-1] === '/') {
288 $info->is_directory
= true;
291 $info->is_directory
= false;
292 $info->size
= (int)$result['size'];
295 if ($this->is_system_file($info)) {
296 // Don't return system files.
304 * Returns array of info about all files in archive.
306 * @return array of file infos
308 public function list_files() {
309 if (!isset($this->za
)) {
315 foreach ($this as $info) {
316 // Simply iterating over $this will give us info only for files we're interested in.
317 array_push($infos, $info);
323 public function is_system_file($fileinfo) {
324 if (substr($fileinfo->pathname
, 0, 8) === '__MACOSX' or substr($fileinfo->pathname
, -9) === '.DS_Store') {
325 // Mac OSX system files.
328 if (substr($fileinfo->pathname
, -9) === 'Thumbs.db') {
329 $stream = $this->za
->getStream($fileinfo->pathname
);
330 $info = base64_encode(fread($stream, 8));
332 if ($info === '0M8R4KGxGuE=') {
333 // It's an OLE Compound File - so it's almost certainly a Windows thumbnail cache.
341 * Returns number of files in archive.
343 * @return int number of files
345 public function count() {
346 if (!isset($this->za
)) {
350 return count($this->list_files());
354 * Returns approximate number of files in archive. This may be a slight
357 * @return int|bool Estimated number of files, or false if not opened
359 public function estimated_count() {
360 if (!isset($this->za
)) {
364 return $this->za
->numFiles
;
368 * Add file into archive.
370 * @param string $localname name of file in archive
371 * @param string $pathname location of file
372 * @return bool success
374 public function add_file_from_pathname($localname, $pathname) {
375 if ($this->emptyziphack
) {
377 $this->open($this->archivepathname
, file_archive
::OVERWRITE
, $this->encoding
);
380 if (!isset($this->za
)) {
384 if ($this->archivepathname
=== realpath($pathname)) {
385 // Do not add self into archive.
389 if (!is_readable($pathname) or is_dir($pathname)) {
393 if (is_null($localname)) {
394 $localname = clean_param($pathname, PARAM_PATH
);
396 $localname = trim($localname, '/'); // No leading slashes in archives!
397 $localname = $this->mangle_pathname($localname);
399 if ($localname === '') {
400 // Sorry - conversion failed badly.
404 if (!$this->za
->addFile($pathname, $localname)) {
407 $this->modified
= true;
412 * Add content of string into archive.
414 * @param string $localname name of file in archive
415 * @param string $contents contents
416 * @return bool success
418 public function add_file_from_string($localname, $contents) {
419 if ($this->emptyziphack
) {
421 $this->open($this->archivepathname
, file_archive
::OVERWRITE
, $this->encoding
);
424 if (!isset($this->za
)) {
428 $localname = trim($localname, '/'); // No leading slashes in archives!
429 $localname = $this->mangle_pathname($localname);
431 if ($localname === '') {
432 // Sorry - conversion failed badly.
436 if ($this->usedmem
> 2097151) {
437 // This prevents running out of memory when adding many large files using strings.
439 $res = $this->open($this->archivepathname
, file_archive
::OPEN
, $this->encoding
);
441 print_error('cannotopenzip');
444 $this->usedmem +
= strlen($contents);
446 if (!$this->za
->addFromString($localname, $contents)) {
449 $this->modified
= true;
454 * Add empty directory into archive.
456 * @param string $localname name of file in archive
457 * @return bool success
459 public function add_directory($localname) {
460 if ($this->emptyziphack
) {
462 $this->open($this->archivepathname
, file_archive
::OVERWRITE
, $this->encoding
);
465 if (!isset($this->za
)) {
468 $localname = trim($localname, '/'). '/';
469 $localname = $this->mangle_pathname($localname);
471 if ($localname === '/') {
472 // Sorry - conversion failed badly.
476 if ($localname !== '') {
477 if (!$this->za
->addEmptyDir($localname)) {
480 $this->modified
= true;
486 * Returns current file info.
490 public function current() {
491 if (!isset($this->za
)) {
495 return $this->get_info($this->pos
);
499 * Returns the index of current file.
501 * @return int current file index
503 public function key() {
508 * Moves forward to next file.
510 public function next() {
515 * Rewinds back to the first file.
517 public function rewind() {
522 * Did we reach the end?
526 public function valid() {
527 if (!isset($this->za
)) {
531 // Skip over unwanted system files (get_info will return false).
532 while (!$this->get_info($this->pos
) && $this->pos
< $this->za
->numFiles
) {
536 // No files left - we're at the end.
537 if ($this->pos
>= $this->za
->numFiles
) {
545 * Create a map of file names used in zip archive.
548 protected function init_namelookup() {
549 if ($this->emptyziphack
) {
550 $this->namelookup
= array();
554 if (!isset($this->za
)) {
557 if (isset($this->namelookup
)) {
561 $this->namelookup
= array();
563 if ($this->mode
!= file_archive
::OPEN
) {
564 // No need to tweak existing names when creating zip file because there are none yet!
568 if (!file_exists($this->archivepathname
)) {
572 if (!$fp = fopen($this->archivepathname
, 'rb')) {
575 if (!$filesize = filesize($this->archivepathname
)) {
579 $centralend = self
::zip_get_central_end($fp, $filesize);
581 if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
582 // Single disk archives only and o support for ZIP64, sorry.
587 fseek($fp, $centralend['offset']);
588 $data = fread($fp, $centralend['size']);
591 for($i=0; $i<$centralend['entries']; $i++
) {
592 $file = self
::zip_parse_file_header($data, $centralend, $pos);
593 if ($file === false) {
594 // Wrong header, sorry.
602 foreach ($files as $file) {
603 $name = $file['name'];
604 if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
605 // No need to fix ASCII.
606 $name = fix_utf8($name);
608 } else if (!($file['general'] & pow(2, 11))) {
609 // First look for unicode name alternatives.
611 foreach($file['extra'] as $extra) {
612 if ($extra['id'] === 0x7075) {
613 $data = unpack('cversion/Vcrc', substr($extra['data'], 0, 5));
614 if ($data['crc'] === crc32($name)) {
616 $name = substr($extra['data'], 5);
620 if (!$found and !empty($this->encoding
) and $this->encoding
!== 'utf-8') {
621 // Try the encoding from open().
622 $newname = @core_text
::convert($name, $this->encoding
, 'utf-8');
623 $original = core_text
::convert($newname, 'utf-8', $this->encoding
);
624 if ($original === $name) {
629 if (!$found and $file['version'] === 0x315) {
630 // This looks like OS X build in zipper.
631 $newname = fix_utf8($name);
632 if ($newname === $name) {
637 if (!$found and $file['version'] === 0) {
638 // This looks like our old borked Moodle 2.2 file.
639 $newname = fix_utf8($name);
640 if ($newname === $name) {
645 if (!$found and $encoding = get_string('oldcharset', 'langconfig')) {
646 // Last attempt - try the dos/unix encoding from current language.
648 foreach($file['extra'] as $extra) {
649 // In Windows archivers do not usually set any extras with the exception of NTFS flag in WinZip/WinRar.
651 if ($extra['id'] === 0x000a) {
657 if ($windows === true) {
658 switch(strtoupper($encoding)) {
659 case 'ISO-8859-1': $encoding = 'CP850'; break;
660 case 'ISO-8859-2': $encoding = 'CP852'; break;
661 case 'ISO-8859-4': $encoding = 'CP775'; break;
662 case 'ISO-8859-5': $encoding = 'CP866'; break;
663 case 'ISO-8859-6': $encoding = 'CP720'; break;
664 case 'ISO-8859-7': $encoding = 'CP737'; break;
665 case 'ISO-8859-8': $encoding = 'CP862'; break;
666 case 'WINDOWS-1251': $encoding = 'CP866'; break;
669 if ($winchar = get_string('localewincharset', 'langconfig')) {
670 // Most probably works only for zh_cn,
671 // if there are more problems we could add zipcharset to langconfig files.
672 $encoding = $winchar;
677 $newname = @core_text
::convert($name, $encoding, 'utf-8');
678 $original = core_text
::convert($newname, 'utf-8', $encoding);
680 if ($original === $name) {
685 $name = str_replace('\\', '/', $name); // no MS \ separators
686 $name = clean_param($name, PARAM_PATH
); // only safe chars
687 $name = ltrim($name, '/'); // no leading slash
689 if (function_exists('normalizer_normalize')) {
690 $name = normalizer_normalize($name, Normalizer
::FORM_C
);
693 $this->namelookup
[$file['name']] = $name;
698 * Add unicode flag to all files in archive.
700 * NOTE: single disk archives only, no ZIP64 support.
702 * @return bool success, modifies the file contents
704 protected function fix_utf8_flags() {
705 if ($this->emptyziphack
) {
709 if (!file_exists($this->archivepathname
)) {
713 // Note: the ZIP structure is described at http://www.pkware.com/documents/casestudies/APPNOTE.TXT
714 if (!$fp = fopen($this->archivepathname
, 'rb+')) {
717 if (!$filesize = filesize($this->archivepathname
)) {
721 $centralend = self
::zip_get_central_end($fp, $filesize);
723 if ($centralend === false or $centralend['disk'] !== 0 or $centralend['disk_start'] !== 0 or $centralend['offset'] === 0xFFFFFFFF) {
724 // Single disk archives only and o support for ZIP64, sorry.
729 fseek($fp, $centralend['offset']);
730 $data = fread($fp, $centralend['size']);
733 for($i=0; $i<$centralend['entries']; $i++
) {
734 $file = self
::zip_parse_file_header($data, $centralend, $pos);
735 if ($file === false) {
736 // Wrong header, sorry.
741 $newgeneral = $file['general'] |
pow(2, 11);
742 if ($newgeneral === $file['general']) {
743 // Nothing to do with this file.
747 if (preg_match('/^[a-zA-Z0-9_\-\.]*$/', $file['name'])) {
748 // ASCII file names are always ok.
751 if ($file['extra']) {
752 // Most probably not created by php zip ext, better to skip it.
755 if (fix_utf8($file['name']) !== $file['name']) {
756 // Does not look like a valid utf-8 encoded file name, skip it.
760 // Read local file header.
761 fseek($fp, $file['local_offset']);
762 $localfile = unpack('Vsig/vversion_req/vgeneral/vmethod/vmtime/vmdate/Vcrc/Vsize_compressed/Vsize/vname_length/vextra_length', fread($fp, 30));
763 if ($localfile['sig'] !== 0x04034b50) {
769 $file['local'] = $localfile;
773 foreach ($files as $file) {
774 $localfile = $file['local'];
775 // Add the unicode flag in central file header.
776 fseek($fp, $file['central_offset'] +
8);
777 if (ftell($fp) === $file['central_offset'] +
8) {
778 $newgeneral = $file['general'] |
pow(2, 11);
779 fwrite($fp, pack('v', $newgeneral));
781 // Modify local file header too.
782 fseek($fp, $file['local_offset'] +
6);
783 if (ftell($fp) === $file['local_offset'] +
6) {
784 $newgeneral = $localfile['general'] |
pow(2, 11);
785 fwrite($fp, pack('v', $newgeneral));
794 * Read end of central signature of ZIP file.
797 * @param resource $fp
798 * @param int $filesize
801 public static function zip_get_central_end($fp, $filesize) {
802 // Find end of central directory record.
803 fseek($fp, $filesize - 22);
804 $info = unpack('Vsig', fread($fp, 4));
805 if ($info['sig'] === 0x06054b50) {
806 // There is no comment.
807 fseek($fp, $filesize - 22);
808 $data = fread($fp, 22);
810 // There is some comment with 0xFF max size - that is 65557.
811 fseek($fp, $filesize - 65557);
812 $data = fread($fp, 65557);
815 $pos = strpos($data, pack('V', 0x06054b50));
816 if ($pos === false) {
817 // Borked ZIP structure!
820 $centralend = unpack('Vsig/vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_length', substr($data, $pos, 22));
821 if ($centralend['comment_length']) {
822 $centralend['comment'] = substr($data, 22, $centralend['comment_length']);
824 $centralend['comment'] = '';
833 * @param string $data
834 * @param array $centralend
835 * @param int $pos (modified)
836 * @return array|bool file info
838 public static function zip_parse_file_header($data, $centralend, &$pos) {
839 $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));
840 $file['central_offset'] = $centralend['offset'] +
$pos;
842 if ($file['sig'] !== 0x02014b50) {
843 // Borked ZIP structure!
846 $file['name'] = substr($data, $pos, $file['name_length']);
847 $pos = $pos +
$file['name_length'];
848 $file['extra'] = array();
849 $file['extra_data'] = '';
850 if ($file['extra_length']) {
851 $extradata = substr($data, $pos, $file['extra_length']);
852 $file['extra_data'] = $extradata;
853 while (strlen($extradata) > 4) {
854 $extra = unpack('vid/vsize', substr($extradata, 0, 4));
855 $extra['data'] = substr($extradata, 4, $extra['size']);
856 $extradata = substr($extradata, 4+
$extra['size']);
857 $file['extra'][] = $extra;
859 $pos = $pos +
$file['extra_length'];
861 if ($file['comment_length']) {
862 $pos = $pos +
$file['comment_length'];
863 $file['comment'] = substr($data, $pos, $file['comment_length']);
865 $file['comment'] = '';