<?php
declare(strict_types = 1);
/**
 * Dynamically generate streamed 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.
 */
final class DeflateError extends Error { };

final class UnknownMethodError extends Error {
  /** @var int Unknown compression method. */
  public $method;

  public function __construct(int $method) {
    $this->method = $method;
    parent::__construct('unknown compression method');
  }
};

/**
 * 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) {
    $this->file_name = $file_name;
    parent::__construct($message);
  }
};

/**
 * 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) {
    $this->file_name = $file_name;
    parent::__construct($message);
  }
};

/**
 * 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;

  /**
   * 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
  }
};

/**
 * Write generated zip archive to a local file.
 *
 * @api
 *
 * {@example ../examples/06-file_writer.php}
 */
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;
  const FILE_WRITER_STATE_OPEN = 1;
  const FILE_WRITER_STATE_CLOSED = 2;
  const FILE_WRITER_STATE_ERROR = 3;

  /**
   * @api
   *
   * Create a new FileWriter.
   */
  public function __construct() {
    # set state
    $this->state = self::FILE_WRITER_STATE_INIT;
  }

  /**
   * 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 {
    # check state
    if ($this->state !== self::FILE_WRITER_STATE_INIT) {
      # set state, raise error
      $this->state = self::FILE_WRITER_STATE_ERROR;
      throw new Error("invalid file writer state");
    }

    if ($key == 'name') {
      # save name
      $this->path = $val;
    } else {
      # ignore other metadata
    }
  }

  /**
   * Flush metadata and begin streaming archive contents.
   *
   * @return void
   *
   * @throw FileError if output archive could not be opened.
   */
  public function open() : void {
    # check state
    if ($this->state !== self::FILE_WRITER_STATE_INIT) {
      # set state, raise error
      $this->state = self::FILE_WRITER_STATE_ERROR;
      throw new Error("invalid file writer state");
    }

    # open output file
    $this->fh = @fopen($this->path, 'wb');
    if (!$this->fh) {
      throw new FileError($path, "couldn't open file");
    }

    # set state
    $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) {
      # set state, raise error
      $this->state = self::FILE_WRITER_STATE_ERROR;
      throw new Error("invalid output state");
    }

    # write data
    $len = fwrite($this->fh, $data);

    # check for error
    if ($len === false) {
      # set state, raise error
      $this->state = self::FILE_WRITER_STATE_ERROR;
      throw new FileError($this->path, 'fwrite() failed');
    }
  }

  /**
   * Finish writing archive data.
   *
   * @return void
   */
  public function close() : void {
    # check state
    if ($this->state == self::FILE_WRITER_STATE_CLOSED) {
      return;
    } else if ($this->state != self::FILE_WRITER_STATE_OPEN) {
      # set state, raise error
      $this->state = self::FILE_WRITER_STATE_ERROR;
      throw new Error("invalid output state");
    }

    # close file handle
    @fclose($this->fh);

    # set state
    $this->state = self::FILE_WRITER_STATE_CLOSED;
  }
};

/**
 * Write generated zip archive to a stream.
 *
 * @api
 *
 * {@example ../examples/07-stream_writer.php}
 */
final class StreamWriter implements Writer {
  /** @var resource Output stream. */
  public $stream;

  const STREAM_WRITER_STATE_INIT = 0;
  const STREAM_WRITER_STATE_OPEN = 1;
  const STREAM_WRITER_STATE_CLOSED = 2;
  const STREAM_WRITER_STATE_ERROR = 3;

  /**
   * Create a new StreamWriter.
   *
   * @api
   */
  public function __construct($stream) {
    # check stream
    if (!is_resource($stream)) {
      $this->state = self::STREAM_WRITER_STATE_ERROR;
      throw new Error('stream is not a resource');
    }

    # set state, cache stream
    $this->state = self::STREAM_WRITER_STATE_INIT;
    $this->stream = $stream;
  }

  public function set(string $key, string $val) : void {
    # ignore metadata
  }

  public function open() : void {
    # set state
    $this->state = self::STREAM_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::STREAM_WRITER_STATE_OPEN) {
      # set state, raise error
      $this->state = self::STREAM_WRITER_STATE_ERROR;
      throw new Error("invalid output state");
    }

    # write data
    $len = fwrite($this->stream, $data);

    # check for error
    if ($len === false) {
      # set state, raise error
      $this->state = self::STREAM_WRITER_STATE_ERROR;
      throw new Error('fwrite() failed');
    }
  }

  /**
   * Finish writing archive data.
   *
   * @return void
   */
  public function close() : void {
    # check state
    if ($this->state == self::STREAM_WRITER_STATE_CLOSED) {
      return;
    } else if ($this->state != self::STREAM_WRITER_STATE_OPEN) {
      # set state, raise error
      $this->state = self::STREAM_WRITER_STATE_ERROR;
      throw new Error("invalid output state");
    }

    # flush output
    if (!@fflush($this->stream)) {
      # set state, raise error
      $this->state = self::STREAM_WRITER_STATE_ERROR;
      throw new Error("fflush() failed");
    }

    # set state
    $this->state = self::STREAM_WRITER_STATE_CLOSED;
  }
};

/**
 * 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;

  static $DOS_EPOCH = [
    'year'    => 1980,
    'mon'     => 1,
    'mday'    => 1,
    'hours'   => 0,
    'minutes' => 0,
    'seconds' => 0,
  ];

  /**
   * 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);

    # set lower-bound on dates
    if ($d['year'] < 1980) {
      $d = self::$DOS_EPOCH;
    }

    # remove extra years from 1980
    $d['year'] -= 1980;

    $this->dos_date = (
      (($d['year'] & 0x7F) << 9) |
      (($d['mon'] & 0xF) << 5) |
      ($d['mday'] & 0x1F)
    );

    $this->dos_time = (
      (($d['hours'] & 0x3F) << 11) |
      (($d['minutes'] & 0x3F) << 5) |
      (($d['seconds'] & 0x3F) >> 1)
    );
  }
};

/**
 * 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');
  }

  /**
   * 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);
    } else {
      throw new Error('hash context already finalized');
    }
  }

  /**
   * 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
      $d = hash_final($this->ctx, true);
      $this->ctx = null;

      # encode hash as uint32_t
      # (FIXME: endian issue?)
      $this->hash = (
        (ord($d[0]) << 24) |
        (ord($d[1]) << 16) |
        (ord($d[2]) << 8) |
        (ord($d[3]))
      );
    }

    # return encoded result
    return $this->hash;
  }
};

/**
 * 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 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 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 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,
         $method,
         $time,
         $comment,
         $uncompressed_size,
         $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,
    string $name,
    int $method,
    int $time,
    string $comment
  ) {
    # set state
    $this->state = self::ENTRY_STATE_INIT;

    $this->output = $output;
    $this->pos = $pos;
    $this->name = $name;
    $this->method = $method;
    $this->time = $time;
    $this->comment = $comment;

    # check comment length
    if (strlen($comment) >= 0xFFFF) {
      $this->state = self::ENTRY_STATE_ERROR;
      throw new Error('comment too long');
    }

    $this->uncompressed_size = 0;
    $this->compressed_size = 0;
    $this->len = 0;
    $this->date_time = new DateTime($time);

    # init hash context
    $this->hasher = new Hasher();

    # init data filter
    if ($this->method == Methods::DEFLATE) {
      $this->filter = new DeflateFilter($this->output);
    } else if ($this->method == Methods::STORE) {
      $this->filter = new StoreFilter($this->output);
    } else {
      $this->state = self::ENTRY_STATE_ERROR;
      throw new UnknownMethodError($this->method);
    }

    # sanity check path
    $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
      if ($this->state != self::ENTRY_STATE_DATA) {
        throw new Error("invalid entry state");
      }

      # update output size
      $this->uncompressed_size += strlen($data);

      # update hash context
      $this->hasher->write($data);

      $len = $this->filter->write($data);
      $this->compressed_size += $len;

      # return length
      return $len;
    } catch (Exception $e) {
      $this->state = self::ENTRY_STATE_ERROR;
      throw $e;
    }
  }

  ########################
  # 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) {
      throw new Error("invalid entry state");
    }

    # get entry header, update entry length
    $data = $this->get_local_header();

    # write entry header
    $this->output->write($data);

    # set state
    $this->state = self::ENTRY_STATE_DATA;

    # return header length
    return strlen($data);
  }

  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',
      0x01,                       # zip64 extended info header ID (2 bytes)
      0                           # field size (2 bytes)
    );

    # build and return local file header
    return pack('VvvvvvVVVvv',
      0x04034b50,                 # local file header signature (4 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)
      $this->date_time->dos_date, # last mod file date (2 bytes)
      0,                          # crc-32 (4 bytes, zero, in footer)
      0,                          # compressed size (4 bytes, zero, in footer)
      0,                          # uncompressed size (4 bytes, zero, in footer)
      strlen($this->name),        # file name length (2 bytes)
      strlen($extra_data)         # extra field length (2 bytes)
    ) . $this->name . $extra_data;
  }

  ########################
  # 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) {
      $this->state = self::ENTRY_STATE_ERROR;
      throw new Error("invalid entry state");
    }

    # finalize hash context
    $this->hash = $this->hasher->close();
    $this->hasher = null;

    # flush remaining data
    $this->compressed_size += $this->filter->close();

    # get footer
    $data = $this->get_local_footer();

    # write footer to output
    $this->output->write($data);

    # set state
    $this->state = self::ENTRY_STATE_CLOSED;

    # return footer length
    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)
      $this->hash,                # crc-32 (4 bytes)
      $this->compressed_size,     # compressed size (8 bytes, zip64)
      $this->uncompressed_size    # uncompressed size (8 bytes, zip64)
    );
  }

  ##########################
  # 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);
    }

    # build result
    $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)
      ) . $r;
    }

    # return packed data
    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 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;

    # pack and return central header
    return pack('VvvvvvvVVVvvvvvVV',
      0x02014b50,                 # central file header signature (4 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)
      $this->date_time->dos_date, # last mod file date (2 bytes)
      $this->hash,                # crc-32 (4 bytes)
      $compressed_size,           # compressed size (4 bytes)
      $uncompressed_size,         # uncompressed size (4 bytes)
      strlen($this->name),        # file name length (2 bytes)
      strlen($extra_data),        # extra field length (2 bytes)
      strlen($this->comment),     # file comment length (2 bytes)
      0,                          # disk number start (2 bytes)
      0,                          # TODO: internal file attributes (2 bytes)
      0,                          # TODO: external file attributes (4 bytes)
      $pos                        # relative offset of local header (4 bytes)
    ) . $this->name . $extra_data . $this->comment;
  }

  ###################
  # 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) {
      throw new PathError($path, "null path");
    }

    # check for empty path
    if (!strlen($path)) {
      throw new PathError($path, "empty path");
    }

    # check for long path
    if (strlen($path) >= 0xFFFF) {
      throw new PathError($path, "path too long");
    }

    # check for leading slash
    if ($path[0] == '/') {
      throw new PathError($path, "path contains leading slash");
    }

    # check for trailing slash
    if (preg_match('/\/$/', $path)) {
      throw new PathError($path, "path contains trailing slash");
    }

    # check for double slashes
    if (preg_match('/\/\//', $path)) {
      throw new PathError($path, "path contains double slashes");
    }

    # check for backslashes
    if (preg_match('/\\\\/', $path)) {
      throw new PathError($path, "path contains backslashes");
    }

    # check for relative path
    if (preg_match('/^\.\.|\\/\.\.\\/|\.\.$/', $path)) {
      throw new PathError($path, "relative path");
    }
  }
};

/**
 * Dynamically generate streamed zip archives.
 *
 * @api
 *
 * {@example ../examples/01-simple.php}
 */
final class ZipStream {
  const STREAM_STATE_INIT = 0;
  const STREAM_STATE_ENTRY = 1;
  const STREAM_STATE_CLOSED = 2;
  const STREAM_STATE_ERROR = 3;

  # 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' => '',
    'type'    => 'application/zip',
    '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 {
      # set state
      $this->state = self::STREAM_STATE_INIT;

      # set name and args
      $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 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::STREAM_STATE_ERROR;
      throw $e;
    }
  }

  /**
   * 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,
    array &$args = []
  ) : void {
    # get file time
    if (!isset($args['time'])) {
      # get file mtime
      $time = @filemtime($src_path);
      if ($time === false) {
        throw new 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 FileError($src_path, "couldn't open file");
    }

    # read input
    $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,
    array &$args = []
  ) : void {
    if (!is_resource($src)) {
      $this->state = self::STREAM_STATE_ERROR;
      throw new Error('source is not a resource');
    }

    $this->add($dst_path, function(Entry &$e) use (&$src, &$args) {
      # read input
      while (!feof($src)) {
        # read chunk
        $buf = @fread($src, self::READ_BUF_SIZE);

        # check for error
        if ($buf === false) {
          throw new Error("fread() error");
        }

        # write chunk to entry
        $e->write($buf);
      }

      # close input
      if (isset($args['close']) && $args['close']) {
        @fclose($src);
      }
    }, $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,
    array $args = []
  ) : void {
    # check state
    if ($this->state != self::STREAM_STATE_INIT) {
      throw new Error("invalid output state");
    }

    # check for duplicate path
    if (isset($this->paths[$dst_path])) {
      throw new Error("duplicate path: $dst_path");
    }
    $this->paths[$dst_path] = true;

    # merge arguments with defaults
    $args = array_merge(self::$FILE_DEFAULTS, $args);

    try {
      # create new entry
      $e = new Entry(
        $this->output,
        $this->pos,
        $dst_path,
        $this->get_entry_method($args),
        $this->get_entry_time($args),
        $args['comment']
      );

      # add to entry list
      $this->entries[] = $e;

      # set state
      $this->state = self::STREAM_STATE_ENTRY;

      # write entry local header
      $header_len = $e->write_local_header();

      # pass entry to callback
      $cb($e);

      # write entry local footer
      $footer_len = $e->write_local_footer();

      # update output position
      $this->pos += $header_len + $e->compressed_size + $footer_len;

      # set state
      $this->state = self::STREAM_STATE_INIT;
    } catch (Exception $e) {
      # set error state, re-throw exception
      $this->state = self::STREAM_STATE_ERROR;
      throw $e;
    }
  }

  /**
   * 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) {
        throw new Error("invalid archive state");
      }

      # cache cdr offset, write cdr, get cdr length
      $cdr_pos = $this->pos;
      $cdr_len = array_reduce($this->entries, function($r, $e) {
        return $r + $e->write_central_header();
      }, 0);

      # update position
      $this->pos += $cdr_len;

      # cache zip64 end of cdr position
      $zip64_cdr_pos = $this->pos;

      # write zip64 end cdr record
      $data = $this->get_zip64_end_of_central_directory_record($cdr_pos, $cdr_len);
      $this->output->write($data);
      $this->pos += strlen($data);

      # write zip64 end cdr locator
      $data = $this->get_zip64_end_of_central_directory_locator($zip64_cdr_pos);
      $this->output->write($data);
      $this->pos += strlen($data);

      # write end cdr record
      $data = $this->get_end_of_central_directory_record($cdr_pos, $cdr_len);
      $this->output->write($data);
      $this->pos += strlen($data);

      # close output
      $this->output->close();

      # return total archive length
      return $this->pos;
    } catch (Exception $e) {
      $this->state = self::STREAM_STATE_ERROR;
      throw $e;
    }
  }

  /**
   * 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,
    array $args = []
  ) : int {
    # 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();
  }

  ####################################
  # central directory record methods #
  ####################################

  /**
   * 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)
      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 {
    return pack('VVPV',
      0x07064b50,     # zip64 end of central dir locator signature (4 bytes)
      0,              # number of the disk with the start of the zip64 end of central directory (4 bytes)
      $zip64_cdr_pos, # relative offset of the zip64 end of central directory record (8 bytes)
      1               # total number of disks (4 bytes)
    );
  }

  /**
   * 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 {
    # get entry count
    $num_entries = count($this->entries);
    if ($num_entries >= 0xFFFF) {
      # clamp entry count
      $num_entries = 0xFFFF;
    }

    # 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, check length
    $comment = $this->args['comment'];
    if (strlen($comment) >= 0xFFFF) {
      throw new Error('comment too long');
    }

    return pack('VvvvvVVv',
      0x06054b50,       # end of central dir signature (4 bytes)
      0,                # number of this disk (2 bytes)
      0,                # disk with the start of the central directory (2 bytes)
      $num_entries,     # number of entries in the central directory on this disk (2 bytes)
      $num_entries,     # number of entries in the central directory (2 bytes)
      $cdr_len,         # size of the central directory (4 bytes)
      $cdr_pos,         # offset of start of central directory with respect to the starting disk number (4 bytes)
      strlen($comment)  # .ZIP file comment length (2 bytes)
    ) . $comment;
  }

  ###################
  # 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'];
    } else if (isset($this->args['time'])) {
      return $this->args['time'];
    } else {
      return time();
    }
  }

  /**
   * 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'])) {
      $r = $args['method'];
    } else if (isset($this->args['method'])) {
      $r = $this->args['method'];
    } else {
      # fall back to default method
      $r = Methods::DEFLATE;
    }

    if ($r != Methods::DEFLATE && $r != Methods::STORE) {
      throw new UnknownMethodError($r);
    }

    return $r;
  }
};