1<?php
2
3namespace splitbrain\PHPArchive;
4
5/**
6 * Class Tar
7 *
8 * Creates or extracts Tar archives. Supports gz and bzip compression
9 *
10 * Long pathnames (>100 chars) are supported in POSIX ustar and GNU longlink formats.
11 *
12 * @author  Andreas Gohr <andi@splitbrain.org>
13 * @package splitbrain\PHPArchive
14 * @license MIT
15 */
16class Tar extends Archive
17{
18    const READ_CHUNK_SIZE = 1048576; // 1MB
19
20    protected $file = '';
21    protected $comptype = Archive::COMPRESS_AUTO;
22    protected $complevel = 9;
23    protected $fh;
24    protected $memory = '';
25    protected $closed = true;
26    protected $writeaccess = false;
27    protected $position = 0;
28    protected $contentUntil = 0;
29    protected $skipUntil = 0;
30
31    /**
32     * Sets the compression to use
33     *
34     * @param int $level Compression level (0 to 9)
35     * @param int $type Type of compression to use (use COMPRESS_* constants)
36     * @throws ArchiveIllegalCompressionException
37     */
38    public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO)
39    {
40        $this->compressioncheck($type);
41        if ($level < -1 || $level > 9) {
42            throw new ArchiveIllegalCompressionException('Compression level should be between -1 and 9');
43        }
44        $this->comptype  = $type;
45        $this->complevel = $level;
46        if($level == 0) $this->comptype = Archive::COMPRESS_NONE;
47        if($type == Archive::COMPRESS_NONE) $this->complevel = 0;
48    }
49
50    /**
51     * Open an existing TAR file for reading
52     *
53     * @param string $file
54     * @throws ArchiveIOException
55     * @throws ArchiveIllegalCompressionException
56     */
57    public function open($file)
58    {
59        $this->file = $file;
60
61        // update compression to mach file
62        if ($this->comptype == Tar::COMPRESS_AUTO) {
63            $this->setCompression($this->complevel, $this->filetype($file));
64        }
65
66        // open file handles
67        if ($this->comptype === Archive::COMPRESS_GZIP) {
68            $this->fh = @gzopen($this->file, 'rb');
69        } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
70            $this->fh = @bzopen($this->file, 'r');
71        } else {
72            $this->fh = @fopen($this->file, 'rb');
73        }
74
75        if (!$this->fh) {
76            throw new ArchiveIOException('Could not open file for reading: '.$this->file);
77        }
78        $this->closed = false;
79        $this->position = 0;
80    }
81
82    /**
83     * Read the contents of a TAR archive
84     *
85     * This function lists the files stored in the archive
86     *
87     * The archive is closed afer reading the contents, because rewinding is not possible in bzip2 streams.
88     * Reopen the file with open() again if you want to do additional operations
89     *
90     * @throws ArchiveIOException
91     * @throws ArchiveCorruptedException
92     * @returns FileInfo[]
93     */
94    public function contents()
95    {
96        $result = array();
97
98        foreach ($this->yieldContents() as $fileinfo) {
99            $result[] = $fileinfo;
100        }
101
102        return $result;
103    }
104
105    /**
106     * Read the contents of a TAR archive and return each entry using yield
107     * for memory efficiency.
108     *
109     * @see contents()
110     * @throws ArchiveIOException
111     * @throws ArchiveCorruptedException
112     * @returns FileInfo[]
113     */
114    public function yieldContents()
115    {
116        if ($this->closed || !$this->file) {
117            throw new ArchiveIOException('Can not read from a closed archive');
118        }
119
120        while ($read = $this->readbytes(512)) {
121            $header = $this->parseHeader($read);
122            if (!is_array($header)) {
123                continue;
124            }
125
126            $this->contentUntil = $this->position + $header['size'];
127            $this->skipUntil = $this->position + ceil($header['size'] / 512) * 512;
128
129            yield $this->header2fileinfo($header);
130
131            $skip = $this->skipUntil - $this->position;
132            if ($skip > 0) {
133                $this->skipbytes($skip);
134            }
135        }
136
137        $this->close();
138    }
139
140    /**
141     * Reads content of a current archive entry.
142     *
143     * Works only when iterating trough the archive using the generator returned
144     * by the yieldContents().
145     *
146     * @param int $length maximum number of bytes to read
147     *
148     * @return string
149     */
150    public function readCurrentEntry($length = PHP_INT_MAX)
151    {
152        $length = (int) min($length, $this->contentUntil - $this->position);
153        if ($length === 0) {
154            return '';
155        }
156        return $this->readbytes($length);
157    }
158
159    /**
160     * Extract an existing TAR archive
161     *
162     * The $strip parameter allows you to strip a certain number of path components from the filenames
163     * found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
164     * an integer is passed as $strip.
165     * Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
166     * the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
167     *
168     * By default this will extract all files found in the archive. You can restrict the output using the $include
169     * and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
170     * $include is set only files that match this expression will be extracted. Files that match the $exclude
171     * expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
172     * stripped filenames as described above.
173     *
174     * The archive is closed afer reading the contents, because rewinding is not possible in bzip2 streams.
175     * Reopen the file with open() again if you want to do additional operations
176     *
177     * @param string $outdir the target directory for extracting
178     * @param int|string $strip either the number of path components or a fixed prefix to strip
179     * @param string $exclude a regular expression of files to exclude
180     * @param string $include a regular expression of files to include
181     * @throws ArchiveIOException
182     * @throws ArchiveCorruptedException
183     * @return FileInfo[]
184     */
185    public function extract($outdir, $strip = '', $exclude = '', $include = '')
186    {
187        if ($this->closed || !$this->file) {
188            throw new ArchiveIOException('Can not read from a closed archive');
189        }
190
191        $outdir = rtrim($outdir, '/');
192        @mkdir($outdir, 0777, true);
193        if (!is_dir($outdir)) {
194            throw new ArchiveIOException("Could not create directory '$outdir'");
195        }
196
197        $extracted = array();
198        while ($dat = $this->readbytes(512)) {
199            // read the file header
200            $header = $this->parseHeader($dat);
201            if (!is_array($header)) {
202                continue;
203            }
204            $fileinfo = $this->header2fileinfo($header);
205
206            // apply strip rules
207            $fileinfo->strip($strip);
208
209            // skip unwanted files
210            if (!strlen($fileinfo->getPath()) || !$fileinfo->matchExpression($include, $exclude)) {
211                $this->skipbytes(ceil($header['size'] / 512) * 512);
212                continue;
213            }
214
215            // create output directory
216            $output    = $outdir.'/'.$fileinfo->getPath();
217            $directory = ($fileinfo->getIsdir()) ? $output : dirname($output);
218            if (!file_exists($directory)) {
219                mkdir($directory, 0777, true);
220            }
221
222            // extract data
223            if (!$fileinfo->getIsdir()) {
224                $fp = @fopen($output, "wb");
225                if (!$fp) {
226                    throw new ArchiveIOException('Could not open file for writing: '.$output);
227                }
228
229                $size = floor($header['size'] / 512);
230                for ($i = 0; $i < $size; $i++) {
231                    fwrite($fp, $this->readbytes(512), 512);
232                }
233                if (($header['size'] % 512) != 0) {
234                    fwrite($fp, $this->readbytes(512), $header['size'] % 512);
235                }
236
237                fclose($fp);
238                @touch($output, $fileinfo->getMtime());
239                @chmod($output, $fileinfo->getMode());
240            } else {
241                $this->skipbytes(ceil($header['size'] / 512) * 512); // the size is usually 0 for directories
242            }
243
244            if(is_callable($this->callback)) {
245                call_user_func($this->callback, $fileinfo);
246            }
247            $extracted[] = $fileinfo;
248        }
249
250        $this->close();
251        return $extracted;
252    }
253
254    /**
255     * Create a new TAR file
256     *
257     * If $file is empty, the tar file will be created in memory
258     *
259     * @param string $file
260     * @throws ArchiveIOException
261     * @throws ArchiveIllegalCompressionException
262     */
263    public function create($file = '')
264    {
265        $this->file   = $file;
266        $this->memory = '';
267        $this->fh     = 0;
268
269        if ($this->file) {
270            // determine compression
271            if ($this->comptype == Archive::COMPRESS_AUTO) {
272                $this->setCompression($this->complevel, $this->filetype($file));
273            }
274
275            if ($this->comptype === Archive::COMPRESS_GZIP) {
276                $this->fh = @gzopen($this->file, 'wb'.$this->complevel);
277            } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
278                $this->fh = @bzopen($this->file, 'w');
279            } else {
280                $this->fh = @fopen($this->file, 'wb');
281            }
282
283            if (!$this->fh) {
284                throw new ArchiveIOException('Could not open file for writing: '.$this->file);
285            }
286        }
287        $this->writeaccess = true;
288        $this->closed      = false;
289    }
290
291    /**
292     * Add a file to the current TAR archive using an existing file in the filesystem
293     *
294     * @param string $file path to the original file
295     * @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data, empty to take from original
296     * @throws ArchiveCorruptedException when the file changes while reading it, the archive will be corrupt and should be deleted
297     * @throws ArchiveIOException there was trouble reading the given file, it was not added
298     * @throws FileInfoException trouble reading file info, it was not added
299     */
300    public function addFile($file, $fileinfo = '')
301    {
302        if (is_string($fileinfo)) {
303            $fileinfo = FileInfo::fromPath($file, $fileinfo);
304        }
305
306        if ($this->closed) {
307            throw new ArchiveIOException('Archive has been closed, files can no longer be added');
308        }
309
310        // create file header
311        $this->writeFileHeader($fileinfo);
312
313        // write data, but only if we have data to write.
314        // note: on Windows fopen() on a directory will fail, so we prevent
315        // errors on Windows by testing if we have data to write.
316        if (!$fileinfo->getIsdir() && $fileinfo->getSize() > 0) {
317            $read = 0;
318            $fp = @fopen($file, 'rb');
319            if (!$fp) {
320                throw new ArchiveIOException('Could not open file for reading: ' . $file);
321            }
322            while (!feof($fp)) {
323                // for performance reasons read bigger chunks at once
324                $data = fread($fp, self::READ_CHUNK_SIZE);
325                if ($data === false) {
326                    break;
327                }
328                if ($data === '') {
329                    break;
330                }
331                $dataLen = strlen($data);
332                $read += $dataLen;
333                // how much of data read fully fills 512-byte blocks?
334                $passLen = ($dataLen >> 9) << 9;
335                if ($passLen === $dataLen) {
336                    // all - just write the data
337                    $this->writebytes($data);
338                } else {
339                    // directly write what fills 512-byte blocks fully
340                    $this->writebytes(substr($data, 0, $passLen));
341                    // pad the reminder to 512 bytes
342                    $this->writebytes(pack("a512", substr($data, $passLen)));
343                }
344            }
345            fclose($fp);
346
347            if ($read != $fileinfo->getSize()) {
348                $this->close();
349                throw new ArchiveCorruptedException("The size of $file changed while reading, archive corrupted. read $read expected ".$fileinfo->getSize());
350            }
351        }
352
353        if(is_callable($this->callback)) {
354            call_user_func($this->callback, $fileinfo);
355        }
356    }
357
358    /**
359     * Add a file to the current TAR archive using the given $data as content
360     *
361     * @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
362     * @param string          $data     binary content of the file to add
363     * @throws ArchiveIOException
364     */
365    public function addData($fileinfo, $data)
366    {
367        if (is_string($fileinfo)) {
368            $fileinfo = new FileInfo($fileinfo);
369        }
370
371        if ($this->closed) {
372            throw new ArchiveIOException('Archive has been closed, files can no longer be added');
373        }
374
375        $len = strlen($data);
376        $fileinfo->setSize($len);
377        $this->writeFileHeader($fileinfo);
378
379        // write directly everything but the last block which needs padding
380        $passLen = ($len >> 9) << 9;
381        $this->writebytes(substr($data, 0, $passLen));
382        if ($passLen < $len) {
383            $this->writebytes(pack("a512", substr($data, $passLen, 512)));
384        }
385
386        if (is_callable($this->callback)) {
387            call_user_func($this->callback, $fileinfo);
388        }
389    }
390
391    /**
392     * Add the closing footer to the archive if in write mode, close all file handles
393     *
394     * After a call to this function no more data can be added to the archive, for
395     * read access no reading is allowed anymore
396     *
397     * "Physically, an archive consists of a series of file entries terminated by an end-of-archive entry, which
398     * consists of two 512 blocks of zero bytes"
399     *
400     * @link http://www.gnu.org/software/tar/manual/html_chapter/tar_8.html#SEC134
401     * @throws ArchiveIOException
402     */
403    public function close()
404    {
405        if ($this->closed) {
406            return;
407        } // we did this already
408
409        // write footer
410        if ($this->writeaccess) {
411            $this->writebytes(pack("a512", ""));
412            $this->writebytes(pack("a512", ""));
413        }
414
415        // close file handles
416        if ($this->file) {
417            if ($this->comptype === Archive::COMPRESS_GZIP) {
418                gzclose($this->fh);
419            } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
420                bzclose($this->fh);
421            } else {
422                fclose($this->fh);
423            }
424
425            $this->file = '';
426            $this->fh   = 0;
427        }
428
429        $this->writeaccess = false;
430        $this->closed      = true;
431    }
432
433    /**
434     * Returns the created in-memory archive data
435     *
436     * This implicitly calls close() on the Archive
437     * @throws ArchiveIOException
438     */
439    public function getArchive()
440    {
441        $this->close();
442
443        if ($this->comptype === Archive::COMPRESS_AUTO) {
444            $this->comptype = Archive::COMPRESS_NONE;
445        }
446
447        if ($this->comptype === Archive::COMPRESS_GZIP) {
448            return gzencode($this->memory, $this->complevel);
449        }
450        if ($this->comptype === Archive::COMPRESS_BZIP) {
451            return bzcompress($this->memory);
452        }
453        return $this->memory;
454    }
455
456    /**
457     * Save the created in-memory archive data
458     *
459     * Note: It more memory effective to specify the filename in the create() function and
460     * let the library work on the new file directly.
461     *
462     * @param string $file
463     * @throws ArchiveIOException
464     * @throws ArchiveIllegalCompressionException
465     */
466    public function save($file)
467    {
468        if ($this->comptype === Archive::COMPRESS_AUTO) {
469            $this->setCompression($this->complevel, $this->filetype($file));
470        }
471
472        if (!@file_put_contents($file, $this->getArchive())) {
473            throw new ArchiveIOException('Could not write to file: '.$file);
474        }
475    }
476
477    /**
478     * Read from the open file pointer
479     *
480     * @param int $length bytes to read
481     * @return string
482     */
483    protected function readbytes($length)
484    {
485        if ($this->comptype === Archive::COMPRESS_GZIP) {
486            $ret = @gzread($this->fh, $length);
487        } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
488            $ret = @bzread($this->fh, $length);
489        } else {
490            $ret = @fread($this->fh, $length);
491        }
492        $this->position += strlen($ret);
493        return $ret;
494    }
495
496    /**
497     * Write to the open filepointer or memory
498     *
499     * @param string $data
500     * @throws ArchiveIOException
501     * @return int number of bytes written
502     */
503    protected function writebytes($data)
504    {
505        if (!$this->file) {
506            $this->memory .= $data;
507            $written = strlen($data);
508        } elseif ($this->comptype === Archive::COMPRESS_GZIP) {
509            $written = @gzwrite($this->fh, $data);
510        } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
511            $written = @bzwrite($this->fh, $data);
512        } else {
513            $written = @fwrite($this->fh, $data);
514        }
515        if ($written === false) {
516            throw new ArchiveIOException('Failed to write to archive stream');
517        }
518        return $written;
519    }
520
521    /**
522     * Skip forward in the open file pointer
523     *
524     * This is basically a wrapper around seek() (and a workaround for bzip2)
525     *
526     * @param int $bytes seek to this position
527     */
528    protected function skipbytes($bytes)
529    {
530        if ($this->comptype === Archive::COMPRESS_GZIP) {
531            @gzseek($this->fh, $bytes, SEEK_CUR);
532        } elseif ($this->comptype === Archive::COMPRESS_BZIP) {
533            // there is no seek in bzip2, we simply read on
534            // bzread allows to read a max of 8kb at once
535            while($bytes) {
536                $toread = min(8192, $bytes);
537                @bzread($this->fh, $toread);
538                $bytes -= $toread;
539            }
540        } else {
541            @fseek($this->fh, $bytes, SEEK_CUR);
542        }
543        $this->position += $bytes;
544    }
545
546    /**
547     * Write the given file meta data as header
548     *
549     * @param FileInfo $fileinfo
550     * @throws ArchiveIOException
551     */
552    protected function writeFileHeader(FileInfo $fileinfo)
553    {
554        $this->writeRawFileHeader(
555            $fileinfo->getPath(),
556            $fileinfo->getUid(),
557            $fileinfo->getGid(),
558            $fileinfo->getMode(),
559            $fileinfo->getSize(),
560            $fileinfo->getMtime(),
561            $fileinfo->getIsdir() ? '5' : '0'
562        );
563    }
564
565    /**
566     * Write a file header to the stream
567     *
568     * @param string $name
569     * @param int $uid
570     * @param int $gid
571     * @param int $perm
572     * @param int $size
573     * @param int $mtime
574     * @param string $typeflag Set to '5' for directories
575     * @throws ArchiveIOException
576     */
577    protected function writeRawFileHeader($name, $uid, $gid, $perm, $size, $mtime, $typeflag = '')
578    {
579        // handle filename length restrictions
580        $prefix  = '';
581        $namelen = strlen($name);
582        if ($namelen > 100) {
583            $file = basename($name);
584            $dir  = dirname($name);
585            if (strlen($file) > 100 || strlen($dir) > 155) {
586                // we're still too large, let's use GNU longlink
587                $this->writeRawFileHeader('././@LongLink', 0, 0, 0, $namelen, 0, 'L');
588                for ($s = 0; $s < $namelen; $s += 512) {
589                    $this->writebytes(pack("a512", substr($name, $s, 512)));
590                }
591                $name = substr($name, 0, 100); // cut off name
592            } else {
593                // we're fine when splitting, use POSIX ustar
594                $prefix = $dir;
595                $name   = $file;
596            }
597        }
598
599        // values are needed in octal
600        $uid   = sprintf("%6s ", decoct($uid));
601        $gid   = sprintf("%6s ", decoct($gid));
602        $perm  = sprintf("%6s ", decoct($perm));
603        $size  = self::numberEncode($size, 12);
604        $mtime = self::numberEncode($size, 12);
605
606        $data_first = pack("a100a8a8a8a12A12", $name, $perm, $uid, $gid, $size, $mtime);
607        $data_last  = pack("a1a100a6a2a32a32a8a8a155a12", $typeflag, '', 'ustar', '', '', '', '', '', $prefix, "");
608
609        for ($i = 0, $chks = 0; $i < 148; $i++) {
610            $chks += ord($data_first[$i]);
611        }
612
613        for ($i = 156, $chks += 256, $j = 0; $i < 512; $i++, $j++) {
614            $chks += ord($data_last[$j]);
615        }
616
617        $this->writebytes($data_first);
618
619        $chks = pack("a8", sprintf("%6s ", decoct($chks)));
620        $this->writebytes($chks.$data_last);
621    }
622
623    /**
624     * Decode the given tar file header
625     *
626     * @param string $block a 512 byte block containing the header data
627     * @return array|false returns false when this was a null block
628     * @throws ArchiveCorruptedException
629     */
630    protected function parseHeader($block)
631    {
632        if (!$block || strlen($block) != 512) {
633            throw new ArchiveCorruptedException('Unexpected length of header');
634        }
635
636        // null byte blocks are ignored
637        if(trim($block) === '') return false;
638
639        for ($i = 0, $chks = 0; $i < 148; $i++) {
640            $chks += ord($block[$i]);
641        }
642
643        for ($i = 156, $chks += 256; $i < 512; $i++) {
644            $chks += ord($block[$i]);
645        }
646
647        $header = @unpack(
648            "a100filename/a8perm/a8uid/a8gid/a12size/a12mtime/a8checksum/a1typeflag/a100link/a6magic/a2version/a32uname/a32gname/a8devmajor/a8devminor/a155prefix",
649            $block
650        );
651        if (!$header) {
652            throw new ArchiveCorruptedException('Failed to parse header');
653        }
654
655        $return['checksum'] = OctDec(trim($header['checksum']));
656        if ($return['checksum'] != $chks) {
657            throw new ArchiveCorruptedException('Header does not match its checksum');
658        }
659
660        $return['filename'] = trim($header['filename']);
661        $return['perm']     = OctDec(trim($header['perm']));
662        $return['uid']      = OctDec(trim($header['uid']));
663        $return['gid']      = OctDec(trim($header['gid']));
664        $return['size']     = self::numberDecode($header['size']);
665        $return['mtime']    = self::numberDecode($header['mtime']);
666        $return['typeflag'] = $header['typeflag'];
667        $return['link']     = trim($header['link']);
668        $return['uname']    = trim($header['uname']);
669        $return['gname']    = trim($header['gname']);
670
671        // Handle ustar Posix compliant path prefixes
672        if (trim($header['prefix'])) {
673            $return['filename'] = trim($header['prefix']).'/'.$return['filename'];
674        }
675
676        // Handle Long-Link entries from GNU Tar
677        if ($return['typeflag'] == 'L') {
678            // following data block(s) is the filename
679            $filename = trim($this->readbytes(ceil($return['size'] / 512) * 512));
680            // next block is the real header
681            $block  = $this->readbytes(512);
682            $return = $this->parseHeader($block);
683            // overwrite the filename
684            $return['filename'] = $filename;
685        }
686
687        return $return;
688    }
689
690    /**
691     * Creates a FileInfo object from the given parsed header
692     *
693     * @param $header
694     * @return FileInfo
695     */
696    protected function header2fileinfo($header)
697    {
698        $fileinfo = new FileInfo();
699        $fileinfo->setPath($header['filename']);
700        $fileinfo->setMode($header['perm']);
701        $fileinfo->setUid($header['uid']);
702        $fileinfo->setGid($header['gid']);
703        $fileinfo->setSize($header['size']);
704        $fileinfo->setMtime($header['mtime']);
705        $fileinfo->setOwner($header['uname']);
706        $fileinfo->setGroup($header['gname']);
707        $fileinfo->setIsdir((bool) $header['typeflag']);
708
709        return $fileinfo;
710    }
711
712    /**
713     * Checks if the given compression type is available and throws an exception if not
714     *
715     * @param $comptype
716     * @throws ArchiveIllegalCompressionException
717     */
718    protected function compressioncheck($comptype)
719    {
720        if ($comptype === Archive::COMPRESS_GZIP && !function_exists('gzopen')) {
721            throw new ArchiveIllegalCompressionException('No gzip support available');
722        }
723
724        if ($comptype === Archive::COMPRESS_BZIP && !function_exists('bzopen')) {
725            throw new ArchiveIllegalCompressionException('No bzip2 support available');
726        }
727    }
728
729    /**
730     * Guesses the wanted compression from the given file
731     *
732     * Uses magic bytes for existing files, the file extension otherwise
733     *
734     * You don't need to call this yourself. It's used when you pass Archive::COMPRESS_AUTO somewhere
735     *
736     * @param string $file
737     * @return int
738     */
739    public function filetype($file)
740    {
741        // for existing files, try to read the magic bytes
742        if(file_exists($file) && is_readable($file) && filesize($file) > 5) {
743            $fh = @fopen($file, 'rb');
744            if(!$fh) return false;
745            $magic = fread($fh, 5);
746            fclose($fh);
747
748            if(strpos($magic, "\x42\x5a") === 0) return Archive::COMPRESS_BZIP;
749            if(strpos($magic, "\x1f\x8b") === 0) return Archive::COMPRESS_GZIP;
750        }
751
752        // otherwise rely on file name
753        $file = strtolower($file);
754        if (substr($file, -3) == '.gz' || substr($file, -4) == '.tgz') {
755            return Archive::COMPRESS_GZIP;
756        } elseif (substr($file, -4) == '.bz2' || substr($file, -4) == '.tbz') {
757            return Archive::COMPRESS_BZIP;
758        }
759
760        return Archive::COMPRESS_NONE;
761    }
762
763    /**
764     * Decodes numeric values according to the
765     * https://www.gnu.org/software/tar/manual/html_node/Extensions.html#Extensions
766     * (basically with support for big numbers)
767     *
768     * @param string $field
769     * $return int
770     */
771    static public function numberDecode($field)
772    {
773        $firstByte = ord(substr($field, 0, 1));
774        if ($firstByte === 255) {
775            $value = -1 << (8 * strlen($field));
776            $shift = 0;
777            for ($i = strlen($field) - 1; $i >= 0; $i--) {
778                $value += ord(substr($field, $i, 1)) << $shift;
779                $shift += 8;
780            }
781        } elseif ($firstByte === 128) {
782            $value = 0;
783            $shift = 0;
784            for ($i = strlen($field) - 1; $i > 0; $i--) {
785                $value += ord(substr($field, $i, 1)) << $shift;
786                $shift += 8;
787            }
788        } else {
789            $value = octdec(trim($field));
790        }
791        return $value;
792    }
793
794    /**
795     * Encodes numeric values according to the
796     * https://www.gnu.org/software/tar/manual/html_node/Extensions.html#Extensions
797     * (basically with support for big numbers)
798     *
799     * @param int $value
800     * @param int $length field length
801     * @return string
802     */
803    static public function numberEncode($value, $length)
804    {
805        // old implementations leave last byte empty
806        // octal encoding encodes three bits per byte
807        $maxValue = 1 << (($length - 1) * 3);
808        if ($value < 0) {
809            // PHP already stores integers as 2's complement
810            $value = pack(PHP_INT_SIZE === 8 ? 'J' : 'N', (int) $value);
811            $encoded = str_repeat(chr(255), max(1, $length - PHP_INT_SIZE));
812            $encoded .= substr($value, max(0, PHP_INT_SIZE - $length + 1));
813        } elseif ($value >= $maxValue) {
814            $value = pack(PHP_INT_SIZE === 8 ? 'J' : 'N', (int) $value);
815            $encoded = chr(128) . str_repeat(chr(0), max(0, $length - PHP_INT_SIZE - 1));
816            $encoded .= substr($value, max(0, PHP_INT_SIZE - $length + 1));
817        } else {
818            $encoded = sprintf("%" . ($length - 1) . "s ", decoct($value));
819        }
820        return $encoded;
821    }
822}
823
824