From 76236c5939301162e0c466f337157b9235980fc0 Mon Sep 17 00:00:00 2001
From: Paul Duncan <pabs@pablotron.org>
Date: Sat, 1 Sep 2018 12:32:38 -0400
Subject: updated code

---
 src/ZipStream.php | 571 +++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 521 insertions(+), 50 deletions(-)

(limited to 'src')

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;
+    }
+  }
 };
-- 
cgit v1.2.3