aboutsummaryrefslogtreecommitdiff
path: root/src/ZipStream.php
diff options
context:
space:
mode:
Diffstat (limited to 'src/ZipStream.php')
-rw-r--r--src/ZipStream.php571
1 files changed, 521 insertions, 50 deletions
diff --git a/src/ZipStream.php b/src/ZipStream.php
index b6953ef..09fe1f2 100644
--- a/src/ZipStream.php
+++ b/src/ZipStream.php
@@ -1,99 +1,570 @@
<?php
+declare(strict_types = 1);
namespace Pablotron\ZipStream;
-final class ZipStream {
- const VERSION = "0.2.0";
+const VERSION = '0.3.0';
- public $zip_name;
- private $args;
- private $entries = array();
+final class Methods {
+ const STORE = 0;
+ const DEFLATE = 2;
+};
- static $DEFAULT_ARCHIVE_OPTIONS = [
- 'method' => 'deflate'
- 'comment' => '',
- 'header' => true,
- ];
+namespace Pablotron\ZipStream\Errors;
- static $DEFAULT_FILE_OPTIONS = [
- 'comment' => '',
- ];
+class Error extends \Exception { };
+final class FileError extends Error {
+ public $file_name;
+
+ public function __construct(string $file_name, string $message) {
+ $this->file_name = $file_name;
+ parent::__construct($message);
+ }
+};
+
+final class PathError extends Error {
+ public $file_name;
+
+ public function __construct(string $file_name, string $message) {
+ $this->file_name = $file_name;
+ parent::__construct($message);
+ }
+};
+
+namespace Pablotron\ZipStream\Writers;
+
+interface Writer {
+ public function set(string $key, string $val);
+ public function open();
+ public function write(string $data);
+ public function close();
+};
+
+final class HTTPResponseWriter implements Writer {
+ private $args = [];
+
+ public function set(string $key, string $val) {
+ $this->args[$key] = $val;
+ }
+
+ public function open() {
+ # TODO: send http headers
+ }
+
+ public function write(string $data) {
+ echo $data;
+ }
+
+ public function close() {
+ # ignore
+ }
+};
+
+final class FileWriter implements Writer {
+ const STATE_INIT = 0;
+ const STATE_OPEN = 1;
+ const STATE_CLOSED = 2;
+ const STATE_ERROR = 3;
+
+ public $path;
+ private $fh;
+
+ public function __construct($path) {
+ $this->state = self::STATE_INIT;
+ $this->path = $path;
+ }
+
+ public function set(string $key, string $val) {
+ # ignore metadata
+ }
+
+ public function open() {
+ # open output file
+ $this->fh = @fopen($this->path, 'wb');
+ if (!$this->fh) {
+ throw new Errors\FileError($path, "couldn't open file");
+ }
+
+ # set state
+ $this->state = self::STATE_OPEN;
+ }
+
+ public function write(string $data) {
+ # check state
+ if ($this->state != self::STATE_OPEN) {
+ throw new Errors\Error("invalid output state");
+ }
+
+ # write data
+ $len = fwrite($this->fh, $data);
+
+ # check for error
+ if ($len === false) {
+ $this->state = self::STATE_ERROR;
+ throw new Errors\FileError($this->path, 'fwrite() failed');
+ }
+ }
+
+ public function close() {
+ # check state
+ if ($this->state == self::STATE_CLOSED) {
+ return;
+ } else if ($this->state != self::STATE_OPEN) {
+ throw new Errors\Error("invalid output state");
+ }
+
+ # close file handle
+ @fclose($this->fh);
+
+ # set state
+ $this->state = self::STATE_CLOSED;
+ }
+};
+
+namespace Pablotron\ZipStream;
+
+final class Entry {
+ const STATE_INIT = 0;
+ const STATE_DATA = 1;
+ const STATE_CLOSED = 2;
+ const STATE_ERROR = 3;
+
+ public $output,
+ $pos,
+ $name,
+ $method,
+ $time,
+ $comment,
+ $uncompressed_size,
+ $compressed_size,
+ $hash;
+
+ private $len,
+ $hash_context,
+ $state;
public function __construct(
- string $zip_name,
- array $args = array()
+ object &$output, # FIXME: constrain to stream interface
+ int $pos,
+ string $name,
+ int $method,
+ int $time,
+ string $comment
) {
- $this->zip_name = $zip_name;
- $this->args = array_merge(
- self::$DEFAULT_ARCHIVE_OPTIONS,
- $args
- );
+ $this->output = $output;
+ $this->pos = $pos;
+ $this->name = $name;
+ $this->method = $method;
+ $this->time = $time;
+ $this->comment = $comment;
+
+ $this->uncompressed_size = 0;
+ $this->compressed_size = 0;
+ $this->state = self::STATE_INIT;
+ $this->len = 0;
+
+ # init hash context
+ $this->hash_context = hash_init('crc32b');
+
+ # sanity check path
+ $this->check_path($name);
}
- public function add_text(
- string $dst_path,
- array $args = array()
- ) {
- $this->check_path($dst_path);
- $this->entries[] = pack('VvvvvvVVVvv'
+ public function write(string &$data) {
+ try {
+ # check entry state
+ if ($this->state != self::STATE_DATA) {
+ throw new Errors\Error("invalid entry state");
+ }
+
+ # update output size
+ $this->uncompressed_size += strlen($data);
+
+ # update hash context
+ hash_update($this->hash_context, $data);
+
+ if ($this->method === Methods::DEFLATE) {
+ $compressed_data = gzdeflate($data);
+ $this->compressed_size += strlen($compressed_data);
+ } else if ($this->method === Methods::STORE) {
+ $compressed_data = $data;
+ $this->compressed_size += strlen($data);
+ } else {
+ throw new Errors\Error('invalid entry method');
+ }
+
+ # write compressed data to output
+ return $this->output->write($compressed_data);
+ } catch (Exception $e) {
+ $this->state = self::STATE_ERROR;
+ throw $e;
+ }
}
- public function add_path(
- string $dst_path,
- string $src_path = null,
- array $args = array()
- )
+#
+# public function close() {
+# try {
+# if ($this->state != STATE_DATA) {
+# # write empty string to flush header and set state
+# $this->write('');
+# }
+#
+# # write footer
+# $footer_len = $this->write_footer();
+#
+# # update state and length
+# $this->state = STATE_CLOSED;
+# $this->len = $this->compressed_size + $footer_len;
+#
+# # return total entry length
+# return $this->len;
+# } catch (Exception $e) {
+# $this->state = STATE_ERROR;
+# throw $e;
+# }
+# }
+#
- public function add_stream(
- string $dst_path,
- $src_stream,
- array $args = array()
- )
+ ##################
+ # header methods #
+ ##################
+
+ public function write_header() {
+ # check state
+ if ($this->state != self::STATE_INIT) {
+ throw new Errors\Error("invalid entry state");
+ }
+
+ # get entry header, update entry length
+ $data = $this->get_header();
+
+ # write entry header
+ $this->output->write($data);
- public function finish() {
+ # set state
+ $this->state = self::STATE_DATA;
+
+ # return header length
+ return strlen($data);
}
- public static function send($name, array $args, function $cb) {
- $zip = new self($name, $args);
- $cb($zip);
- $zip->finish();
+ private function get_header() {
+ return pack('VvvvvvVVVvv',
+ 0x04034b50, # local file header signature
+ 0 # TODO
+ ) . $this->name . pack(
+ # TODO (extras)
+ );
+ }
+
+ ##################
+ # footer methods #
+ ##################
+
+ public function write_footer() {
+ # check state
+ if ($this->state != self::STATE_DATA) {
+ $this->state = self::STATE_ERROR;
+ throw new Errors\Error("invalid entry state");
+ }
+
+ # finalize hash context
+ $this->hash = hash_final($this->hash_context, true);
+ $this->hash_context = null;
+
+ # get footer
+ $data = $this->get_footer();
+
+ # write footer to output
+ $this->output->write($data);
+
+ # set state
+ $this->state = self::STATE_CLOSED;
+
+ # return footer length
+ return strlen($data);
}
+ private function get_footer() {
+ return ''; # TODO
+ }
+
+ ###################
+ # utility methods #
+ ###################
+
private function check_path(string $path) {
# make sure path is non-null
if (!$path) {
- throw new Exception("null path");
+ throw new Errors\PathError($path, "null path");
}
# check for empty path
if (!strlen($path)) {
- throw new Exception("empty path");
+ throw new Errors\PathError($path, "empty path");
}
# check for long path
if (strlen($path) > 65535) {
- throw new Exception("path too long");
+ throw new Errors\PathError($path, "path too long");
}
# check for leading slash
if (!$path[0] == '/') {
- throw new Exception("path contains leading slash");
+ throw new Errors\PathError($path, "path contains leading slash");
}
# check for trailing slash
if (preg_match('/\\$/', $path)) {
- throw new Exception("path contains trailing slash");
+ throw new Errors\PathError($path, "path contains trailing slash");
}
# check for double slashes
- if (preg_match('/\/\//', $path))
- throw new Exception("path contains double slashes");
+ if (preg_match('/\/\//', $path)) {
+ throw new Errors\PathError($path, "path contains double slashes");
}
# check for double dots
- if (preg_match('/\.\./', $path))
- throw new Exception("relative path");
+ if (preg_match('/\.\./', $path)) {
+ throw new Errors\PathError($path, "relative path");
}
}
+};
+
+final class ZipStream {
+ const VERSION = '0.3.0';
+
+ const STATE_INIT = 0;
+ const STATE_ENTRY = 1;
+ const STATE_CLOSED = 2;
+ const STATE_ERROR = 3;
+
+ # stream chunk size
+ const READ_BUF_SIZE = 8192;
+
+ public $name;
+ private $args,
+ $output,
+ $pos = 0,
+ $entries = [],
+ $paths = [];
+
+ static $ARCHIVE_DEFAULTS = [
+ 'method' => Methods::DEFLATE,
+ 'comment' => '',
+ 'type' => 'application/zip',
+ 'header' => true,
+ ];
+
+ static $FILE_DEFAULTS = [
+ 'comment' => '',
+ ];
+
+ public function __construct(string $name, array &$args = []) {
+ try {
+ $this->state = self::STATE_INIT;
+ $this->name = $name;
+ $this->args = array_merge(self::$ARCHIVE_DEFAULTS, [
+ 'time' => time(),
+ ], $args);
+
+ # initialize output
+ if (isset($args['output']) && $args['output']) {
+ # use specified output writer
+ $this->output = $args['output'];
+ } else {
+ # no output set, create default response writer
+ $this->output = new Writers\HTTPResponseWriter();
+ }
+ # set output metadata
+ $this->output->set('name', $this->name);
+ $this->output->set('type', $this->args['type']);
+
+ # open output
+ $this->output->open();
+ } catch (Exception $e) {
+ $this->state = self::STATE_ERROR;
+ throw $e;
+ }
+ }
+
+ public function add_file(
+ string $dst_path,
+ string $data,
+ array &$args = []
+ ) {
+ $this->add($dst_path, function(Entry &$e) use (&$data) {
+ # write data
+ $e->write($data);
+ }, array_merge(self::$FILE_DEFAULTS, $args));
+ }
+
+ public function add_file_from_path(
+ string $dst_path,
+ string $src_path,
+ array &$args = []
+ ) {
+ # get file time
+ if (!isset($args['time'])) {
+ # get file mtime
+ $time = @filemtime($src_path);
+ if ($time === false) {
+ throw new Errors\FileError($src_path, "couldn't get file mtime");
+ }
+
+ # save file mtime
+ $args['time'] = $time;
+ }
+
+ # close input stream
+ $args['close'] = true;
+
+ # open input stream
+ $fh = @fopen($src_path, 'rb');
+ if (!$fh) {
+ throw new Errors\FileError($src_path, "couldn't open file");
+ }
+
+ # read input
+ $this->add_stream($dst_path, $fh, $args);
+ }
+
+ public function add_stream(
+ string $dst_path,
+ object &$src, # FIXME: limit to input stream
+ array &$args = []
+ ) {
+ $this->add($dst_path, function(Entry &$e) use (&$src, &$args) {
+ # read input
+ while (!feof($src)) {
+ # read chunk
+ $buf = @fread($src, READ_BUF_SIZE);
+
+ # check for error
+ if ($buf === false) {
+ throw new Errors\Error("file read error");
+ }
+
+ # write chunk to entry
+ $e->write($buf);
+ }
+
+ # close input
+ if (isset($args['close']) && $args['close']) {
+ @fclose($src);
+ }
+ }, $args);
+ }
+
+ public function add(string $dst_path, callable $cb, array &$args = []) {
+ # check state
+ if ($this->state != self::STATE_INIT) {
+ throw new Errors\Error("invalid output state");
+ }
+
+ # check for duplicate path
+ if (isset($this->paths[$dst_path])) {
+ throw new Errors\Error("duplicate path: $dst_path");
+ }
+ $this->paths[$dst_path] = true;
+
+ # merge arguments with defaults
+ $args = array_merge(self::$FILE_DEFAULTS, $args);
+
+ try {
+ # get method
+ $method = $this->get_method($args);
+
+ # create new entry
+ $e = new Entry(
+ $this->output,
+ $this->pos,
+ $dst_path,
+ $method,
+ $this->get_time($args),
+ $args['comment']
+ );
+
+ # add to entry list
+ $this->entries[] = $e;
+
+ # set state
+ $this->state = self::STATE_ENTRY;
+
+ # write entry header
+ $header_len = $e->write_header();
+
+ # pass entry to callback
+ $cb($e);
+
+ # write entry footer
+ $footer_len = $e->write_footer();
+
+ # update output position
+ $this->pos += $header_len + $e->compressed_size + $footer_len;
+
+ # set state
+ $this->state = self::STATE_INIT;
+ } catch (Exception $e) {
+ # set error state, re-throw exception
+ $this->state = self::STATE_ERROR;
+ throw $e;
+ }
+ }
+
+ public function close() {
+ try {
+ if ($this->state != self::STATE_INIT) {
+ throw new Errors\Error("invalid archive state");
+ }
+
+ # TODO: write cdr
+ # TODO: write archive footer
+
+ # close output
+ $this->output->close();
+
+ # return total archive length
+ return $this->pos;
+ } catch (Exception $e) {
+ $this->state = self::STATE_ERROR;
+ throw $e;
+ }
+ }
+
+ public static function send(string $name, callable $cb, array &$args = []) {
+ # create archive
+ $zip = new self($name, $args);
+
+ # pass archive to callback
+ $cb($zip);
+
+ # close archive and return total number of bytes written
+ return $zip->close();
+ }
+
+ ###################
+ # utility methods #
+ ###################
+
+ private function get_time(array &$args) {
+ if (isset($args['time'])) {
+ return $args['time'];
+ } else if (isset($this->args['time'])) {
+ return $this->args['time'];
+ } else {
+ return time();
+ }
+ }
+
+ private function get_method(array &$args) {
+ if (isset($args['method'])) {
+ return $args['method'];
+ } else if (isset($this->args['method'])) {
+ return $this->args['method'];
+ } else {
+ return METHOD_DEFLATE;
+ }
+ }
};