diff options
-rw-r--r-- | src/ZipStream.php | 641 |
1 files changed, 610 insertions, 31 deletions
diff --git a/src/ZipStream.php b/src/ZipStream.php index 33218b6..0f3a452 100644 --- a/src/ZipStream.php +++ b/src/ZipStream.php @@ -1,17 +1,53 @@ <?php declare(strict_types = 1); +/** + * Streamed, dynamically generated zip archives. + * + * @author Paul Duncan <pabs@pablotron.org> + * @copyright 2007-2018 Paul Duncan <pabs@pablotron.org> + * @license MIT + * @package Pablotron\ZipStream + */ namespace Pablotron\ZipStream; +/** + * Current version of ZipStream. + */ const VERSION = '0.3.0'; +/** + * Version needed to extract. + * @internal + */ +const VERSION_NEEDED = 45; + +/** + * Valid compression methods. + */ final class Methods { + /** Store file without compression. */ const STORE = 0; + + /** Store file using DEFLATE compression. */ const DEFLATE = 8; }; +/** + * Base class for all exceptions raised by ZipStream. + */ class Error extends \Exception { }; + +/** + * Deflate context error. + */ +class DeflateError extends Error { }; + +/** + * File related error. + */ final class FileError extends Error { + /** @var string Name of fail in which error occurred. */ public $file_name; public function __construct(string $file_name, string $message) { @@ -20,7 +56,11 @@ final class FileError extends Error { } }; +/** + * Path validation error. + */ final class PathError extends Error { + /** @var string string Invalid file name. */ public $file_name; public function __construct(string $file_name, string $message) { @@ -29,35 +69,126 @@ final class PathError extends Error { } }; +/** + * Abstract interface for stream output. + * + * @api + * + * @see FileWriter, HTTPResponseWriter + */ interface Writer { + /** + * Set metadata for generated archive. + * + * @param string $key Metadata key (one of "name" or "type"). + * @param string $val Metadata value. + * + * @return void + */ public function set(string $key, string $val) : void; - public function open() : void ; + + /** + * Flush metadata and begin streaming archive contents. + * + * @return void + */ + public function open() : void; + + /** + * Write archive contents. + * + * @param string $data Archive file data. + * + * @return void + */ public function write(string $data) : void; + + /** + * Finish writing archive data. + * + * @return void + */ public function close() : void; }; +/** + * Stream generated archive as an HTTP response. + * + * @api + * + * Streams generated zip archive as an HTTP response. This is the + * default writer used by ZipStream if none is provided. + * + * @see Writer + */ final class HTTPResponseWriter implements Writer { + /** + * @var array Hash of metadata. + * @internal + */ private $args = []; + /** + * Set metadata for generated archive. + * + * @param string $key Metadata key (one of "name" or "type"). + * @param string $val Metadata value. + * + * @return void + */ public function set(string $key, string $val) : void { $this->args[$key] = $val; } + /** + * Flush metadata and begin streaming archive contents. + * + * @todo + * @return void + */ public function open() : void { # TODO: send http headers } + /** + * Write archive contents. + * + * @param string $data Archive file data. + * + * @return void + */ public function write(string $data) : void { echo $data; } + /** + * Finish writing archive data. + * + * @return void + */ public function close() : void { # ignore } }; +/** + * @api + * + * Stream generated archive to a local file. + * + * Streams generated zip archive to a local file. This is the + * default writer used by ZipStream if none is provided. + * + * @see Writer + */ final class FileWriter implements Writer { + /** @var string Output file path. */ public $path; + + /** + * @var resource Output file handle. + * @internal + */ private $fh; const FILE_WRITER_STATE_INIT = 0; @@ -65,15 +196,35 @@ final class FileWriter implements Writer { const FILE_WRITER_STATE_CLOSED = 2; const FILE_WRITER_STATE_ERROR = 3; - public function __construct($path) { + /** + * @api + * + * Create a new FileWriter. + * + * @param string $path Output file path. + */ + public function __construct(string $path) { $this->state = self::FILE_WRITER_STATE_INIT; $this->path = $path; } + /** + * Set metadata for generated archive. + * + * @param string $key Metadata key (one of "name" or "type"). + * @param string $val Metadata value. + * + * @return void + */ public function set(string $key, string $val) : void { # ignore metadata } + /** + * Flush metadata and begin streaming archive contents. + * + * @return void + */ public function open() : void { # open output file $this->fh = @fopen($this->path, 'wb'); @@ -85,6 +236,13 @@ final class FileWriter implements Writer { $this->state = self::FILE_WRITER_STATE_OPEN; } + /** + * Write archive contents. + * + * @param string $data Archive file data. + * + * @return void + */ public function write(string $data) : void { # check state if ($this->state != self::FILE_WRITER_STATE_OPEN) { @@ -101,6 +259,11 @@ final class FileWriter implements Writer { } } + /** + * Finish writing archive data. + * + * @return void + */ public function close() : void { # check state if ($this->state == self::FILE_WRITER_STATE_CLOSED) { @@ -117,10 +280,16 @@ final class FileWriter implements Writer { } }; -# -# Convert a UNIX timestamp to a DOS time/date. -# +/** + * Convert a UNIX timestamp into DOS date and time components. + * @internal + */ final class DateTime { + /** + * @var int $time Input UNIX timestamp. + * @var int $dos_time Output DOS timestamp. + * @var int $dos_date Output DOS datestamp. + */ public $time, $dos_time, $dos_date; @@ -134,8 +303,22 @@ final class DateTime { 'seconds' => 0, ]; - public function __construct($time) { + /** + * Convert a UNIX timestamp into DOS date and time components. + * + * @param int $time Input UNIX timestamp. + * + * @example + * # create DateTime with current timestamp + * $dt = new DateTime(time()); + * + * # print DOS date and time + * echo "DOS time: {$dt->dos_time}\n"; + * echo "DOS date: {$dt->dos_date}\n"; + */ + public function __construct(int $time) { $this->time = $time; + # get date array for timestamp $d = getdate($time); @@ -152,15 +335,32 @@ final class DateTime { } }; +/** + * CRC32b hash context wrapper. + * @internal + */ final class Hasher { + /** @var int $hash Output hash result. */ public $hash; + + /** @var object $ctx Internal hash context. */ private $ctx; + /** + * Create a new Hasher. + */ public function __construct() { $this->ctx = hash_init('crc32b'); } - public function write($data) : void { + /** + * Write data to Hasher. + * + * @param string $data Input data. + * @return void + * @throw Error if called after close(). + */ + public function write(string $data) : void { if ($this->ctx !== null) { # update hash context hash_update($this->ctx, $data); @@ -169,6 +369,14 @@ final class Hasher { } } + /** + * Create hash of input data. + * + * Finalize the internal has context and return a CRC32b hash of the + * given input data. + * + * @return int CRC32b hash of input data. + */ public function close() : int { if ($this->ctx !== null) { # finalize hash context @@ -190,67 +398,182 @@ final class Hasher { } }; +/** + * Abstract base class for data filter methods. + * + * @internal + * @see StoreFilter, DeflateFilter + */ abstract class DataFilter { + /** @var Writer output writer */ private $output; + /** + * Create a new DataFilter bound to the given Writer. + * + * @param Writer $output Output writer. + */ public function __construct(Writer &$output) { $this->output = $output; } + /** + * Write data to data filter. + * + * @param string $data Output data. + * @return int Number of bytes written. + */ public function write(string $data) : int { $this->output->write($data); return strlen($data); } + /** + * Close this data filter. + * + * @return int Number of bytes written. + */ public abstract function close() : int; } +/** + * Data filter for files using the store compression method. + * + * @internal + * @see DataFilter, DeflateFilter + */ final class StoreFilter extends DataFilter { + /** + * Close this data filter. + * + * @return int Number of bytes written. + */ public function close() : int { return 0; } }; +/** + * Data filter for files using the deflate compression method. + * + * @internal + * + * @see DataFilter, StoreFilter + */ final class DeflateFilter extends DataFilter { + /** @var object $ctx Deflate context. */ private $ctx; + /** + * Create a new DeflateFilter bound to the given Writer. + * + * @param Writer $output Output writer. + * + * @throw DeflateError If initializing deflate context fails. + */ public function __construct(Writer &$output) { + # init parent parent::__construct($output); + # init deflate context, check for error $this->ctx = deflate_init(ZLIB_ENCODING_RAW); if ($this->ctx === false) { - throw new Error('deflate_init() failed'); + throw new DeflateError('deflate_init() failed'); } } + /** + * Write data to data filter. + * + * @param string $data Output data. + * + * @return int Number of bytes written. + * + * @throw DeflateError If writing to deflate context fails. + * @throw Error If this filter was already closed. + */ public function write(string $data) : int { + # check state + if (!$this->ctx) { + # filter already closed + throw new Error('Filter already closed'); + } + + # write data to deflate context, check for error $compressed_data = deflate_add($this->ctx, $data, ZLIB_NO_FLUSH); if ($compressed_data === false) { - throw new Error('deflate_add() failed'); + throw new DeflateError('deflate_add() failed'); } + # pass data to parent return parent::write($compressed_data); } + /** + * Close this data filter. + * + * @return int Number of bytes written. + * + * @throw DeflateError If writing to deflate context fails. + * @throw Error If this filter was already closed. + */ public function close() : int { + # check state + if (!$this->ctx) { + # filter already closed + throw new Error('Filter already closed'); + } + + # finalize context, flush remaining data $compressed_data = deflate_add($this->ctx, '', ZLIB_FINISH); if ($compressed_data === false) { - throw new Error('deflate_add() failed'); + throw new DeflateError('deflate_add() failed'); } # clear deflate context $this->ctx = null; + # write remaining data, return number of bytes written return parent::write($compressed_data); } }; +/** + * Internal representation for a zip file. + * + * @internal + * + * @see DataFilter, StoreFilter + */ final class Entry { const ENTRY_STATE_INIT = 0; const ENTRY_STATE_DATA = 1; const ENTRY_STATE_CLOSED = 2; const ENTRY_STATE_ERROR = 3; + /** + * @var Writer $output + * Reference to output writer. + * @var int $pos + * Offset from start of file of entry local header. + * @var string $name + * Name of file. + * @var int $method + * Compression method. + * @var int $time + * File creation time (UNIX timestamp). + * @var string $comment + * File comment. + * @var int $uncompressed_size + * Raw file size, in bytes. Only valid after file has been + * read completely. + * @var int $compressed_size + * Compressed file size, in bytes. Only valid after file has + * been read completely. + * @var int $hash + * CRC32b hash of file contents. Only valid after file has + * been read completely. + */ public $output, $pos, $name, @@ -261,11 +584,43 @@ final class Entry { $compressed_size, $hash; + /** + * @var int $len + * File length, in bytes. Only valid after file has been read + * completely. + * @var DateTime $date_time + * Date and time converter for this file. + * @var Hasher $hasher + * Internal hasher for this file. + * @var int $state + * Entry state. + */ private $len, $date_time, $hasher, $state; + /** + * Create a new entry object. + * + * @internal + * + * @param Writer $output + * Reference to output writer. + * @param int $pos + * Offset from start of file of entry local header. + * @param string $name + * Name of file. + * @param int $method + * Compression method. + * @param int $time + * File creation time (UNIX timestamp). + * @param string $comment + * File comment. + * + * @throw Error if compression method is unknown. + * @throw PathError if compression method is unknown. + */ public function __construct( Writer &$output, int $pos, @@ -274,6 +629,9 @@ final class Entry { int $time, string $comment ) { + # set state + $this->state = self::ENTRY_STATE_INIT; + $this->output = $output; $this->pos = $pos; $this->name = $name; @@ -283,7 +641,6 @@ final class Entry { $this->uncompressed_size = 0; $this->compressed_size = 0; - $this->state = self::ENTRY_STATE_INIT; $this->len = 0; $this->date_time = new DateTime($time); @@ -303,6 +660,17 @@ final class Entry { $this->check_path($name); } + /** + * Write data to file entry. + * + * @api + * + * @param string $data Output file data. + * + * @throw Error if entry state is invalid. + * + * @return int Number of bytes written to output. + */ public function write(string &$data) : int { try { # check entry state @@ -331,6 +699,15 @@ final class Entry { # local header methods # ######################## + /** + * Write local file header. + * + * @internal + * + * @throw Error if entry state is invalid. + * + * @return int Number of bytes written to output. + */ public function write_local_header() : int { # check state if ($this->state != self::ENTRY_STATE_INIT) { @@ -353,6 +730,11 @@ final class Entry { const ENTRY_VERSION_NEEDED = 45; const ENTRY_BIT_FLAGS = 0b100000001000; + /** + * Create local file header for this entry. + * + * @return string Packed local file header. + */ private function get_local_header() : string { # build extra data $extra_data = pack('vv', @@ -363,7 +745,7 @@ final class Entry { # build and return local file header return pack('VvvvvvVVVvv', 0x04034b50, # local file header signature (4 bytes) - self::ENTRY_VERSION_NEEDED, # version needed to extract (2 bytes) + VERSION_NEEDED, # version needed to extract (2 bytes) self::ENTRY_BIT_FLAGS, # general purpose bit flag (2 bytes) $this->method, # compression method (2 bytes) $this->date_time->dos_time, # last mod file time (2 bytes) @@ -380,6 +762,15 @@ final class Entry { # local footer methods # ######################## + /** + * Write local file footer (e.g. data descriptor). + * + * @internal + * + * @throw Error if entry state is invalid. + * + * @return int Number of bytes written to output. + */ public function write_local_footer() : int { # check state if ($this->state != self::ENTRY_STATE_DATA) { @@ -407,6 +798,11 @@ final class Entry { return strlen($data); } + /** + * Create local file footer (data descriptor) for this entry. + * + * @return string Packed local file footer. + */ private function get_local_footer() : string { return pack('VVPP', 0x08074b50, # data descriptor signature (4 bytes) @@ -420,25 +816,40 @@ final class Entry { # central header methods # ########################## + /** + * Write central directory entry for this file to output writer. + * + * @internal + * + * @return int Number of bytes written to output. + */ public function write_central_header() : int { $data = $this->get_central_header(); $this->output->write($data); return strlen($data); } + /** + * Create extra data for central directory entry for this file. + * + * @return string Packed extra data for central directory entry. + */ private function get_central_extra_data() : string { $r = []; + # check uncompressed size for overflow if ($this->uncompressed_size >= 0xFFFFFFFF) { # append 64-bit uncompressed size $r[] = pack('P', $this->uncompressed_size); } + # check compressed size for overflow if ($this->compressed_size >= 0xFFFFFFFF) { # append 64-bit compressed size $r[] = pack('P', $this->compressed_size); } + # check offset for overflow if ($this->pos >= 0xFFFFFFFF) { # append 64-bit file offset $r[] = pack('P', $this->pos); @@ -448,6 +859,7 @@ final class Entry { $r = join('', $r); if (strlen($r) > 0) { + # has overflow, so generate zip64 info $r = pack('vv', 0x01, # zip64 ext. info extra tag (2 bytes) strlen($r) # size of this extra block (2 bytes) @@ -458,10 +870,15 @@ final class Entry { return $r; } + /** + * Create central directory entry for this file. + * + * @return string Packed central directory entry. + */ private function get_central_header() : string { $extra_data = $this->get_central_extra_data(); - # get sizes and offset + # get compressed size, uncompressed size, and offset $compressed_size = ($this->compressed_size >= 0xFFFFFFFF) ? 0xFFFFFFFF : $this->compressed_size; $uncompressed_size = ($this->uncompressed_size >= 0xFFFFFFFF) ? 0xFFFFFFFF : $this->uncompressed_size; $pos = ($this->pos >= 0xFFFFFFFF) ? 0xFFFFFFFF : $this->pos; @@ -469,8 +886,8 @@ final class Entry { # pack and return central header return pack('VvvvvvvVVVvvvvvVV', 0x02014b50, # central file header signature (4 bytes) - self::ENTRY_VERSION_NEEDED, # FIXME: version made by (2 bytes) - self::ENTRY_VERSION_NEEDED, # version needed to extract (2 bytes) + VERSION_NEEDED, # FIXME: version made by (2 bytes) + VERSION_NEEDED, # version needed to extract (2 bytes) self::ENTRY_BIT_FLAGS, # general purpose bit flag (2 bytes) $this->method, # compression method (2 bytes) $this->date_time->dos_time, # last mod file time (2 bytes) @@ -492,6 +909,27 @@ final class Entry { # utility methods # ################### + /** + * Check file path. + * + * Verifies that the given file path satisfies the following + * constraints: + * + * * Path is not null. + * * Path not empty. + * * Path is less than 65535 bytes in length. + * * Path does not contain a leading slash. + * * Path does not contain a trailing slash. + * * Path does not contain double slashes. + * * Path does not contain backslashes. + * * Path is not a relative path. + * + * @param string $path Input file path. + * + * @return void + * + * @throw PathError if path is invalid. + */ private function check_path(string $path) : void { # make sure path is non-null if (!$path) { @@ -529,12 +967,19 @@ final class Entry { } # check for relative path - if (preg_match('/\.\./', $path)) { + if (preg_match('/^\.\.|\\/\.\.\\/|\.\.$/', $path)) { throw new PathError($path, "relative path"); } } }; +/** + * Dynamically generate streamed zip file. + * + * @api + * + * {@example ../examples/01-simple.php} + */ final class ZipStream { const STREAM_STATE_INIT = 0; const STREAM_STATE_ENTRY = 1; @@ -544,13 +989,25 @@ final class ZipStream { # stream chunk size const READ_BUF_SIZE = 8192; + /** @var string Output archive name. */ public $name; + + /** + * @var array $args Hash of options. + * @var Writer $output output Writer. + * @var int $pos Current byte offset in output stream. + * @var array $entries Array of archive entries. + */ private $args, $output, $pos = 0, $entries = [], $paths = []; + /** + * Default archive options. + * @internal + */ static $ARCHIVE_DEFAULTS = [ 'method' => Methods::DEFLATE, 'comment' => '', @@ -558,10 +1015,22 @@ final class ZipStream { 'header' => true, ]; + /** + * Default file options. + * @internal + */ static $FILE_DEFAULTS = [ 'comment' => '', ]; + /** + * Create a new ZipStream object. + * + * @param string $name Output archive name. + * @param array $args Hash of output options (optional). + * + * {@example ../examples/01-simple.php} + */ public function __construct(string $name, array &$args = []) { try { $this->state = self::STREAM_STATE_INIT; @@ -591,17 +1060,41 @@ final class ZipStream { } } + /** + * Add file to output archive. + * + * @param string $dst_path Destination path in output archive. + * @param string $data File contents. + * @param array $args File options (optional). + * + * @return void + * + * {@example ../examples/01-simple.php} + */ public function add_file( string $dst_path, string $data, array $args = [] - ) { + ) : void { $this->add($dst_path, function(Entry &$e) use (&$data) { # write data $e->write($data); }, array_merge(self::$FILE_DEFAULTS, $args)); } + /** + * Add file on the local file system to output archive. + * + * @param string $dst_path Destination path in output archive. + * @param string $src_path Path to input file. + * @param array $args File options (optional). + * + * @return void + * + * {@example ../examples/02-add_file_from_path.php} + * + * @throw FileError if the file could not be opened or read. + */ public function add_file_from_path( string $dst_path, string $src_path, @@ -632,6 +1125,20 @@ final class ZipStream { $this->add_stream($dst_path, $fh, $args); } + /** + * Add contents of resource stream to output archive. + * + * @param string $dst_path Destination path in output archive. + * @param resource $src Input resource stream. + * @param array $args File options (optional). + * + * @return void + * + * {@example ../examples/03-add_stream.php} + * + * @throw Error if $src is not a resource. + * @throw Error if the resource could not be read. + */ public function add_stream( string $dst_path, &$src, @@ -650,7 +1157,7 @@ final class ZipStream { # check for error if ($buf === false) { - throw new Error("file read error"); + throw new Error("fread() error"); } # write chunk to entry @@ -664,6 +1171,20 @@ final class ZipStream { }, $args); } + /** + * Dynamically write file contents to output archive. + * + * @param string $dst_path Destination path in output archive. + * @param callable $cb Write callback. + * @param array $args File options (optional). + * + * @return void + * + * {@example ../examples/04-add.php} + * + * @throw Error if the archive is in an invalid state. + * @throw Error if the destination path already exists. + */ public function add( string $dst_path, callable $cb, @@ -724,6 +1245,15 @@ final class ZipStream { } } + /** + * Finalize the output stream. + * + * @return int Total number of bytes written. + * + * @throw Error if the archive is in an invalid state. + * + * {@example ../examples/01-simple.php} + */ public function close() : int { try { if ($this->state != self::STREAM_STATE_INIT) { @@ -768,6 +1298,15 @@ final class ZipStream { } } + /** + * Create an archive and send it using a single function. + * + * @param string $name Name of output archive. + * @param callable $cb Context callback. + * @param array $args Hash of archive options (optional). + * + * {@example ../examples/05-send.php} + */ public static function send( string $name, callable $cb, @@ -787,30 +1326,44 @@ final class ZipStream { # central directory record methods # #################################### - const VERSION_NEEDED = 45; - + /** + * Get Zip64 end of Central Directory Record (CDR) + * + * @param int $cdr_pos CDR offset, in bytes. + * @param int $cdr_len Size of CDR, in bytes. + * + * @return string Packed Zip64 end of Central Directory Record. + */ private function get_zip64_end_of_central_directory_record( int $cdr_pos, int $cdr_len ) : string { + # get entry count $num_entries = count($this->entries); return pack('VPvvVVPPPP', 0x06064b50, # zip64 end of central dir signature (4 bytes) 44, # size of zip64 end of central directory record (8 bytes) - self::VERSION_NEEDED, # FIXME: version made by (2 bytes) - self::VERSION_NEEDED, # version needed to extract (2 bytes) - 0, # number of this disk (4 bytes) - 0, # number of the disk with the start of the central directory (4 bytes) - $num_entries, # total number of entries in the central directory on this disk (8 bytes) - $num_entries, # total number of entries in the central directory (8 bytes) - $cdr_len, # size of the central directory (8 bytes) - $cdr_pos # offset of start of central directory with respect to the starting disk number (8 bytes) + VERSION_NEEDED, # FIXME: version made by (2 bytes) + VERSION_NEEDED, # version needed to extract (2 bytes) + 0, # number of this disk (4 bytes) + 0, # number of the disk with the start of the central directory (4 bytes) + $num_entries, # total number of entries in the central directory on this disk (8 bytes) + $num_entries, # total number of entries in the central directory (8 bytes) + $cdr_len, # size of the central directory (8 bytes) + $cdr_pos # offset of start of central directory with respect to the starting disk number (8 bytes) # zip64 extensible data sector (variable size) # (FIXME: is extensible data sector needed?) ); } + /** + * Get Zip64 End of Central Directory Record (CDR) Locator. + * + * @param int $zip64_cdr_pos Zip64 End of CDR offset, in bytes. + * + * @return string Packed Zip64 End of CDR Locator. + */ private function get_zip64_end_of_central_directory_locator( int $zip64_cdr_pos ) : string { @@ -822,22 +1375,34 @@ final class ZipStream { ); } + /** + * Get End of Central Directory Record (CDR) Locator. + * + * @param int $cdr_pos CDR offset, in bytes. + * @param int $cdr_len CDR size, in bytes. + * + * @return string End of CDR Record. + */ private function get_end_of_central_directory_record( int $cdr_pos, int $cdr_len ) : string { - # clamp num_entries + # get entry count $num_entries = count($this->entries); if ($num_entries >= 0xFFFF) { + # clamp entry count $num_entries = 0xFFFF; } - # clamp cdr_len and cdr_pos + # get/clamp cdr_len and cdr_pos $cdr_len = ($cdr_len >= 0xFFFFFFFF) ? 0xFFFFFFFF : $cdr_len; $cdr_pos = ($cdr_pos >= 0xFFFFFFFF) ? 0xFFFFFFFF : $cdr_pos; - # get comment + # get comment, check length $comment = $this->args['comment']; + if (strlen($comment) >= 65535) { + throw new Error('comment too long'); + } return pack('VvvvvVVv', 0x06054b50, # end of central dir signature (4 bytes) @@ -855,6 +1420,13 @@ final class ZipStream { # utility methods # ################### + /** + * Get UNIX timestamp for given entry. + * + * @param array $args Entry options. + * + * @return int UNIX timestamp. + */ private function get_entry_time(array &$args) : int { if (isset($args['time'])) { return $args['time']; @@ -865,6 +1437,13 @@ final class ZipStream { } } + /** + * Get compression method for given entry. + * + * @param array $args Entry options. + * + * @return int Compression method. + */ private function get_entry_method(array &$args) : int { if (isset($args['method'])) { return $args['method']; |