barracudanetworks/ArchiveStream-php

View on GitHub
src/ZipArchive.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php
namespace Barracuda\ArchiveStream;

/**
 * Zip-formatted streaming archive.
 */
class ZipArchive extends Archive
{
    /**
     * Version zip was created by / must be opened by (4.5 for Zip64 support).
     */
    const VERSION = 45;

    /**
     * Array of specified options for the archive.
     * @var array
     */
    public $opt = array();

    /**
     * Files added to the archive, tracked for the CDR.
     * @var array
     */
    private $files = array();

    /**
     * Length of the CDR.
     * @var int
     */
    private $cdr_len = 0;

    /**
     * Offset of the CDR.
     * @var int
     */
    private $cdr_ofs = 0;

    /**
     * Rolling count of the file length being streamed.
     * @var int
     */
    private $len = null;

    /**
     * Rolling count of the compressed file length being streamed.
     * @var int
     */
    private $zlen = null;

    /**
     * Create a new ArchiveStream_Zip object.
     *
     * @see ArchiveStream for documentation
     * @access public
     */
    public function __construct()
    {
        $this->opt['content_type'] = 'application/x-zip';
        call_user_func_array(array('parent', '__construct'), func_get_args());
    }

    /**
     * Explicitly adds a directory to the tar (necessary for empty directories)
     *
     * @param  string $name Name (path) of the directory.
     * @param  array  $opt  Additional options to set ("type" will be overridden).
     * @return void
     */
    public function add_directory($name, array $opt = array())
    {
        // calculate header attributes
        $this->meth_str = 'deflate';
        $meth = 0x08;

        if (substr($name, -1) != '/')
        {
            $name = $name . '/';
        }

        // send header
        $this->init_file_stream_transfer($name, $size = 0, $opt, $meth);

        // complete the file stream
        $this->complete_file_stream();
    }

    /**
     * Initialize a file stream
     *
     * @param string $name File path or just name.
     * @param int    $size Size in bytes of the file.
     * @param array  $opt  Array containing time / type (optional).
     * @param int    $meth Method of compression to use (defaults to store).
     * @return void
     */
    public function init_file_stream_transfer($name, $size, array $opt = array(), $meth = 0x00)
    {
        // if we're using a container directory, prepend it to the filename
        if ($this->use_container_dir)
        {
            // the container directory will end with a '/' so ensure the filename doesn't start with one
            $name = $this->container_dir_name . preg_replace('/^\\/+/', '', $name);
        }

        $algo = 'crc32b';

        // calculate header attributes
        $this->len = gmp_init(0);
        $this->zlen = gmp_init(0);
        $this->hash_ctx = hash_init($algo);

        // Send file header
        $this->add_stream_file_header($name, $size, $opt, $meth);
    }

    /**
     * Stream the next part of the current file stream.
     *
     * @param string $data        Raw data to send.
     * @param bool   $single_part Used to determine if we can compress.
     * @return void
     */
    public function stream_file_part($data, $single_part = false)
    {
        $this->len = gmp_add(gmp_init(strlen($data)), $this->len);
        hash_update($this->hash_ctx, $data);

        if ($single_part === true && isset($this->meth_str) && $this->meth_str == 'deflate')
        {
            $data = gzdeflate($data);
        }

        $this->zlen = gmp_add(gmp_init(strlen($data)), $this->zlen);

        // send data
        $this->send($data);
        flush();
    }

    /**
     * Complete the current file stream (zip64 format).
     *
     * @return void
     */
    public function complete_file_stream()
    {
        $crc = hexdec(hash_final($this->hash_ctx));

        // convert the 64 bit ints to 2 32bit ints
        list($zlen_low, $zlen_high) = $this->int64_split($this->zlen);
        list($len_low, $len_high) = $this->int64_split($this->len);

        // build data descriptor
        $fields = array(                // (from V.A of APPNOTE.TXT)
            array('V', 0x08074b50),     // data descriptor
            array('V', $crc),           // crc32 of data
            array('V', $zlen_low),      // compressed data length (low)
            array('V', $zlen_high),     // compressed data length (high)
            array('V', $len_low),       // uncompressed data length (low)
            array('V', $len_high),      // uncompressed data length (high)
        );

        // pack fields and calculate "total" length
        $ret = $this->pack_fields($fields);

        // print header and filename
        $this->send($ret);

        // Update cdr for file record
        $this->current_file_stream[3] = $crc;
        $this->current_file_stream[4] = gmp_strval($this->zlen);
        $this->current_file_stream[5] = gmp_strval($this->len);
        $this->current_file_stream[6] += gmp_strval(gmp_add(gmp_init(strlen($ret)), $this->zlen));
        ksort($this->current_file_stream);

        // Add to cdr and increment offset - can't call directly because we pass an array of params
        call_user_func_array(array($this, 'add_to_cdr'), $this->current_file_stream);
    }

    /**
     * Finish an archive
     *
     * @return void
     */
    public function finish()
    {
        // adds an error log file if we've been tracking errors
        $this->add_error_log();

        // add trailing cdr record
        $this->add_cdr($this->opt);
        $this->clear();
    }

    /*******************
     * PRIVATE METHODS *
     *******************/

    /**
     * Add initial headers for file stream
     *
     * @param string $name File path or just name.
     * @param int    $size Size in bytes of the file.
     * @param array  $opt  Array containing time.
     * @param int    $meth Method of compression to use.
     * @return void
     */
    protected function add_stream_file_header($name, $size, array $opt, $meth)
    {
        // strip leading slashes from file name
        // (fixes bug in windows archive viewer)
        $name = preg_replace('/^\\/+/', '', $name);
        $extra = pack('vVVVV', 1, 0, 0, 0, 0);

        // create dos timestamp
        $opt['time'] = isset($opt['time']) ? $opt['time'] : time();
        $dts = $this->dostime($opt['time']);

        // Sets bit 3, which means CRC-32, uncompressed and compresed length
        // are put in the data descriptor following the data. This gives us time
        // to figure out the correct sizes, etc.
        $genb = 0x08;

        if (mb_check_encoding($name, "UTF-8") && !mb_check_encoding($name, "ASCII"))
        {
            // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
            // the filename and comment fields for this file
            // MUST be encoded using UTF-8. (see APPENDIX D)
            $genb |= 0x0800;
        }

        // build file header
        $fields = array(                // (from V.A of APPNOTE.TXT)
            array('V', 0x04034b50),     // local file header signature
            array('v', self::VERSION),  // version needed to extract
            array('v', $genb),          // general purpose bit flag
            array('v', $meth),          // compresion method (deflate or store)
            array('V', $dts),           // dos timestamp
            array('V', 0x00),           // crc32 of data (0x00 because bit 3 set in $genb)
            array('V', 0xFFFFFFFF),     // compressed data length
            array('V', 0xFFFFFFFF),     // uncompressed data length
            array('v', strlen($name)),  // filename length
            array('v', strlen($extra)), // extra data len
        );

        // pack fields and calculate "total" length
        $ret = $this->pack_fields($fields);

        // print header and filename
        $this->send($ret . $name . $extra);

        // Keep track of data for central directory record
        $this->current_file_stream = array(
            $name,
            $opt,
            $meth,
            // 3-5 will be filled in by complete_file_stream()
            6 => (strlen($ret) + strlen($name) + strlen($extra)),
            7 => $genb,
            8 => substr($name, -1) == '/' ? 0x10 : 0x20, // 0x10 for directory, 0x20 for file
        );
    }

    /**
     * Save file attributes for trailing CDR record.
     *
     * @param string $name    Path / name of the file.
     * @param array  $opt     Array containing time.
     * @param int    $meth    Method of compression to use.
     * @param string $crc     Computed checksum of the file.
     * @param int    $zlen    Compressed size.
     * @param int    $len     Uncompressed size.
     * @param int    $rec_len Size of the record.
     * @param int    $genb    General purpose bit flag.
     * @param int    $fattr   File attribute bit flag.
     * @return void
     */
    private function add_to_cdr($name, array $opt, $meth, $crc, $zlen, $len, $rec_len, $genb = 0, $fattr = 0x20)
    {
        $this->files[] = array($name, $opt, $meth, $crc, $zlen, $len, $this->cdr_ofs, $genb, $fattr);
        $this->cdr_ofs += $rec_len;
    }

    /**
     * Send CDR record for specified file (Zip64 format).
     *
     * @see add_to_cdr() for options to pass in $args.
     * @param array $args Array of argumentss.
     * @return void
     */
    private function add_cdr_file(array $args)
    {
        list($name, $opt, $meth, $crc, $zlen, $len, $ofs, $genb, $file_attribute) = $args;

        // convert the 64 bit ints to 2 32bit ints
        list($zlen_low, $zlen_high) = $this->int64_split($zlen);
        list($len_low, $len_high)   = $this->int64_split($len);
        list($ofs_low, $ofs_high)   = $this->int64_split($ofs);

        // ZIP64, necessary for files over 4GB (incl. entire archive size)
        $extra_zip64 = '';
        $extra_zip64 .= pack('VV', $len_low, $len_high);
        $extra_zip64 .= pack('VV', $zlen_low, $zlen_high);
        $extra_zip64 .= pack('VV', $ofs_low, $ofs_high);

        $extra = pack('vv', 1, strlen($extra_zip64)) . $extra_zip64;

        // get attributes
        $comment = isset($opt['comment']) ? $opt['comment'] : '';

        // get dos timestamp
        $dts = $this->dostime($opt['time']);

        $fields = array(                      // (from V,F of APPNOTE.TXT)
            array('V', 0x02014b50),           // central file header signature
            array('v', self::VERSION),        // version made by
            array('v', self::VERSION),        // version needed to extract
            array('v', $genb),                // general purpose bit flag
            array('v', $meth),                // compresion method (deflate or store)
            array('V', $dts),                 // dos timestamp
            array('V', $crc),                 // crc32 of data
            array('V', 0xFFFFFFFF),           // compressed data length (zip64 - look in extra)
            array('V', 0xFFFFFFFF),           // uncompressed data length (zip64 - look in extra)
            array('v', strlen($name)),        // filename length
            array('v', strlen($extra)),       // extra data len
            array('v', strlen($comment)),     // file comment length
            array('v', 0),                    // disk number start
            array('v', 0),                    // internal file attributes
            array('V', $file_attribute),      // external file attributes, 0x10 for dir, 0x20 for file
            array('V', 0xFFFFFFFF),           // relative offset of local header (zip64 - look in extra)
        );

        // pack fields, then append name and comment
        $ret = $this->pack_fields($fields) . $name . $extra . $comment;

        $this->send($ret);

        // increment cdr length
        $this->cdr_len += strlen($ret);
    }

    /**
     * Adds Zip64 end of central directory record.
     *
     * @return void
     */
    private function add_cdr_eof_zip64()
    {
        $num = count($this->files);

        list($num_low, $num_high) = $this->int64_split($num);
        list($cdr_len_low, $cdr_len_high) = $this->int64_split($this->cdr_len);
        list($cdr_ofs_low, $cdr_ofs_high) = $this->int64_split($this->cdr_ofs);

        $fields = array(                    // (from V,F of APPNOTE.TXT)
            array('V', 0x06064b50),         // zip64 end of central directory signature
            array('V', 44),                 // size of zip64 end of central directory record (low) minus 12 bytes
            array('V', 0),                  // size of zip64 end of central directory record (high)
            array('v', self::VERSION),      // version made by
            array('v', self::VERSION),      // version needed to extract
            array('V', 0x0000),             // this disk number (only one disk)
            array('V', 0x0000),             // number of disk with central dir
            array('V', $num_low),           // number of entries in the cdr for this disk (low)
            array('V', $num_high),          // number of entries in the cdr for this disk (high)
            array('V', $num_low),           // number of entries in the cdr (low)
            array('V', $num_high),          // number of entries in the cdr (high)
            array('V', $cdr_len_low),       // cdr size (low)
            array('V', $cdr_len_high),      // cdr size (high)
            array('V', $cdr_ofs_low),       // cdr ofs (low)
            array('V', $cdr_ofs_high),      // cdr ofs (high)
        );

        $ret = $this->pack_fields($fields);
        $this->send($ret);
    }

    /**
     * Add location record for ZIP64 central directory
     *
     * @return void
     */
    private function add_cdr_eof_locator_zip64()
    {
        list($cdr_ofs_low, $cdr_ofs_high) = $this->int64_split($this->cdr_len + $this->cdr_ofs);

        $fields = array(                    // (from V,F of APPNOTE.TXT)
            array('V', 0x07064b50),         // zip64 end of central dir locator signature
            array('V', 0),                  // this disk number
            array('V', $cdr_ofs_low),       // cdr ofs (low)
            array('V', $cdr_ofs_high),      // cdr ofs (high)
            array('V', 1),                  // total number of disks
        );

        $ret = $this->pack_fields($fields);
        $this->send($ret);
    }

    /**
     * Send CDR EOF (Central Directory Record End-of-File) record. Most values
     * point to the corresponding values in the ZIP64 CDR. The optional comment
     * still goes in this CDR however.
     *
     * @param array $opt Options array that may contain a comment.
     * @return void
     */
    private function add_cdr_eof(array $opt = null)
    {
        // grab comment (if specified)
        $comment = '';
        if ($opt && isset($opt['comment']))
        {
            $comment = $opt['comment'];
        }

        $fields = array(                    // (from V,F of APPNOTE.TXT)
            array('V', 0x06054b50),         // end of central file header signature
            array('v', 0xFFFF),             // this disk number (0xFFFF to look in zip64 cdr)
            array('v', 0xFFFF),             // number of disk with cdr (0xFFFF to look in zip64 cdr)
            array('v', 0xFFFF),             // number of entries in the cdr on this disk (0xFFFF to look in zip64 cdr))
            array('v', 0xFFFF),             // number of entries in the cdr (0xFFFF to look in zip64 cdr)
            array('V', 0xFFFFFFFF),         // cdr size (0xFFFFFFFF to look in zip64 cdr)
            array('V', 0xFFFFFFFF),         // cdr offset (0xFFFFFFFF to look in zip64 cdr)
            array('v', strlen($comment)),   // zip file comment length
        );

        $ret = $this->pack_fields($fields) . $comment;
        $this->send($ret);
    }

    /**
     * Add CDR (Central Directory Record) footer.
     *
     * @param array $opt Options array that may contain a comment.
     * @return void
     */
    private function add_cdr(array $opt = null)
    {
        foreach ($this->files as $file)
        {
            $this->add_cdr_file($file);
        }

        $this->add_cdr_eof_zip64();
        $this->add_cdr_eof_locator_zip64();

        $this->add_cdr_eof($opt);
    }

    /**
     * Clear all internal variables.
     *
     * Note: the archive object is unusable after this.
     *
     * @return void
     */
    private function clear()
    {
        $this->files = array();
        $this->cdr_ofs = 0;
        $this->cdr_len = 0;
        $this->opt = array();
    }
}