first commit

This commit is contained in:
aschwarz
2022-11-28 10:27:30 +01:00
commit 9015dd2102
2720 changed files with 605111 additions and 0 deletions

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace ZipStream;
use OverflowException;
class Bigint
{
/**
* @var int[]
*/
private $bytes = [0, 0, 0, 0, 0, 0, 0, 0];
/**
* Initialize the bytes array
*
* @param int $value
*/
public function __construct(int $value = 0)
{
$this->fillBytes($value, 0, 8);
}
/**
* Fill the bytes field with int
*
* @param int $value
* @param int $start
* @param int $count
* @return void
*/
protected function fillBytes(int $value, int $start, int $count): void
{
for ($i = 0; $i < $count; $i++) {
$this->bytes[$start + $i] = $i >= PHP_INT_SIZE ? 0 : $value & 0xFF;
$value >>= 8;
}
}
/**
* Get an instance
*
* @param int $value
* @return Bigint
*/
public static function init(int $value = 0): self
{
return new self($value);
}
/**
* Fill bytes from low to high
*
* @param int $low
* @param int $high
* @return Bigint
*/
public static function fromLowHigh(int $low, int $high): self
{
$bigint = new Bigint();
$bigint->fillBytes($low, 0, 4);
$bigint->fillBytes($high, 4, 4);
return $bigint;
}
/**
* Get high 32
*
* @return int
*/
public function getHigh32(): int
{
return $this->getValue(4, 4);
}
/**
* Get value from bytes array
*
* @param int $end
* @param int $length
* @return int
*/
public function getValue(int $end = 0, int $length = 8): int
{
$result = 0;
for ($i = $end + $length - 1; $i >= $end; $i--) {
$result <<= 8;
$result |= $this->bytes[$i];
}
return $result;
}
/**
* Get low FF
*
* @param bool $force
* @return float
*/
public function getLowFF(bool $force = false): float
{
if ($force || $this->isOver32()) {
return (float)0xFFFFFFFF;
}
return (float)$this->getLow32();
}
/**
* Check if is over 32
*
* @param bool $force
* @return bool
*/
public function isOver32(bool $force = false): bool
{
// value 0xFFFFFFFF already needs a Zip64 header
return $force ||
max(array_slice($this->bytes, 4, 4)) > 0 ||
min(array_slice($this->bytes, 0, 4)) === 0xFF;
}
/**
* Get low 32
*
* @return int
*/
public function getLow32(): int
{
return $this->getValue(0, 4);
}
/**
* Get hexadecimal
*
* @return string
*/
public function getHex64(): string
{
$result = '0x';
for ($i = 7; $i >= 0; $i--) {
$result .= sprintf('%02X', $this->bytes[$i]);
}
return $result;
}
/**
* Add
*
* @param Bigint $other
* @return Bigint
*/
public function add(Bigint $other): Bigint
{
$result = clone $this;
$overflow = false;
for ($i = 0; $i < 8; $i++) {
$result->bytes[$i] += $other->bytes[$i];
if ($overflow) {
$result->bytes[$i]++;
$overflow = false;
}
if ($result->bytes[$i] & 0x100) {
$overflow = true;
$result->bytes[$i] &= 0xFF;
}
}
if ($overflow) {
throw new OverflowException;
}
return $result;
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ZipStream;
class DeflateStream extends Stream
{
protected $filter;
/**
* @var Option\File
*/
protected $options;
/**
* Rewind stream
*
* @return void
*/
public function rewind(): void
{
// deflate filter needs to be removed before rewind
if ($this->filter) {
$this->removeDeflateFilter();
$this->seek(0);
$this->addDeflateFilter($this->options);
} else {
rewind($this->stream);
}
}
/**
* Remove the deflate filter
*
* @return void
*/
public function removeDeflateFilter(): void
{
if (!$this->filter) {
return;
}
stream_filter_remove($this->filter);
$this->filter = null;
}
/**
* Add a deflate filter
*
* @param Option\File $options
* @return void
*/
public function addDeflateFilter(Option\File $options): void
{
$this->options = $options;
// parameter 4 for stream_filter_append expects array
// so we convert the option object in an array
$optionsArr = [
'comment' => $options->getComment(),
'method' => $options->getMethod(),
'deflateLevel' => $options->getDeflateLevel(),
'time' => $options->getTime()
];
$this->filter = stream_filter_append(
$this->stream,
'zlib.deflate',
STREAM_FILTER_READ,
$optionsArr
);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ZipStream;
/**
* This class is only for inheriting
*/
abstract class Exception extends \Exception
{
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if file or comment encoding is incorrect
*/
class EncodingException extends Exception
{
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if a file wasn't found
*/
class FileNotFoundException extends Exception
{
/**
* Constructor of the Exception
*
* @param String $path - The path which wasn't found
*/
public function __construct(string $path)
{
parent::__construct("The file with the path $path wasn't found.");
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if a file wasn't found
*/
class FileNotReadableException extends Exception
{
/**
* Constructor of the Exception
*
* @param String $path - The path which wasn't found
*/
public function __construct(string $path)
{
parent::__construct("The file with the path $path isn't readable.");
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if options are incompatible
*/
class IncompatibleOptionsException extends Exception
{
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if a counter value exceeds storage size
*/
class OverflowException extends Exception
{
public function __construct()
{
parent::__construct('File size exceeds limit of 32 bit integer. Please enable "zip64" option.');
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ZipStream\Exception;
use ZipStream\Exception;
/**
* This Exception gets invoked if `fread` fails on a stream.
*/
class StreamNotReadableException extends Exception
{
/**
* Constructor of the Exception
*
* @param string $fileName - The name of the file which the stream belongs to.
*/
public function __construct(string $fileName)
{
parent::__construct("The stream for $fileName could not be read.");
}
}

View File

@ -0,0 +1,477 @@
<?php
declare(strict_types=1);
namespace ZipStream;
use Psr\Http\Message\StreamInterface;
use ZipStream\Exception\EncodingException;
use ZipStream\Exception\FileNotFoundException;
use ZipStream\Exception\FileNotReadableException;
use ZipStream\Exception\OverflowException;
use ZipStream\Option\File as FileOptions;
use ZipStream\Option\Method;
use ZipStream\Option\Version;
class File
{
const HASH_ALGORITHM = 'crc32b';
const BIT_ZERO_HEADER = 0x0008;
const BIT_EFS_UTF8 = 0x0800;
const COMPUTE = 1;
const SEND = 2;
private const CHUNKED_READ_BLOCK_SIZE = 1048576;
/**
* @var string
*/
public $name;
/**
* @var FileOptions
*/
public $opt;
/**
* @var Bigint
*/
public $len;
/**
* @var Bigint
*/
public $zlen;
/** @var int */
public $crc;
/**
* @var Bigint
*/
public $hlen;
/**
* @var Bigint
*/
public $ofs;
/**
* @var int
*/
public $bits;
/**
* @var Version
*/
public $version;
/**
* @var ZipStream
*/
public $zip;
/**
* @var resource
*/
private $deflate;
/**
* @var resource
*/
private $hash;
/**
* @var Method
*/
private $method;
/**
* @var Bigint
*/
private $totalLength;
public function __construct(ZipStream $zip, string $name, ?FileOptions $opt = null)
{
$this->zip = $zip;
$this->name = $name;
$this->opt = $opt ?: new FileOptions();
$this->method = $this->opt->getMethod();
$this->version = Version::STORE();
$this->ofs = new Bigint();
}
public function processPath(string $path): void
{
if (!is_readable($path)) {
if (!file_exists($path)) {
throw new FileNotFoundException($path);
}
throw new FileNotReadableException($path);
}
if ($this->zip->isLargeFile($path) === false) {
$data = file_get_contents($path);
$this->processData($data);
} else {
$this->method = $this->zip->opt->getLargeFileMethod();
$stream = new DeflateStream(fopen($path, 'rb'));
$this->processStream($stream);
$stream->close();
}
}
public function processData(string $data): void
{
$this->len = new Bigint(strlen($data));
$this->crc = crc32($data);
// compress data if needed
if ($this->method->equals(Method::DEFLATE())) {
$data = gzdeflate($data);
}
$this->zlen = new Bigint(strlen($data));
$this->addFileHeader();
$this->zip->send($data);
$this->addFileFooter();
}
/**
* Create and send zip header for this file.
*
* @return void
* @throws \ZipStream\Exception\EncodingException
*/
public function addFileHeader(): void
{
$name = static::filterFilename($this->name);
// calculate name length
$nameLength = strlen($name);
// create dos timestamp
$time = static::dosTime($this->opt->getTime()->getTimestamp());
$comment = $this->opt->getComment();
if (!mb_check_encoding($name, 'ASCII') ||
!mb_check_encoding($comment, 'ASCII')) {
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
// the filename and comment fields for this file
// MUST be encoded using UTF-8. (see APPENDIX D)
if (!mb_check_encoding($name, 'UTF-8') ||
!mb_check_encoding($comment, 'UTF-8')) {
throw new EncodingException(
'File name and comment should use UTF-8 ' .
'if one of them does not fit into ASCII range.'
);
}
$this->bits |= self::BIT_EFS_UTF8;
}
if ($this->method->equals(Method::DEFLATE())) {
$this->version = Version::DEFLATE();
}
$force = (boolean)($this->bits & self::BIT_ZERO_HEADER) &&
$this->zip->opt->isEnableZip64();
$footer = $this->buildZip64ExtraBlock($force);
// If this file will start over 4GB limit in ZIP file,
// CDR record will have to use Zip64 extension to describe offset
// to keep consistency we use the same value here
if ($this->zip->ofs->isOver32()) {
$this->version = Version::ZIP64();
}
$fields = [
['V', ZipStream::FILE_HEADER_SIGNATURE],
['v', $this->version->getValue()], // Version needed to Extract
['v', $this->bits], // General purpose bit flags - data descriptor flag set
['v', $this->method->getValue()], // Compression method
['V', $time], // Timestamp (DOS Format)
['V', $this->crc], // CRC32 of data (0 -> moved to data descriptor footer)
['V', $this->zlen->getLowFF($force)], // Length of compressed data (forced to 0xFFFFFFFF for zero header)
['V', $this->len->getLowFF($force)], // Length of original data (forced to 0xFFFFFFFF for zero header)
['v', $nameLength], // Length of filename
['v', strlen($footer)], // Extra data (see above)
];
// pack fields and calculate "total" length
$header = ZipStream::packFields($fields);
// print header and filename
$data = $header . $name . $footer;
$this->zip->send($data);
// save header length
$this->hlen = Bigint::init(strlen($data));
}
/**
* Strip characters that are not legal in Windows filenames
* to prevent compatibility issues
*
* @param string $filename Unprocessed filename
* @return string
*/
public static function filterFilename(string $filename): string
{
// strip leading slashes from file name
// (fixes bug in windows archive viewer)
$filename = preg_replace('/^\\/+/', '', $filename);
return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename);
}
/**
* Convert a UNIX timestamp to a DOS timestamp.
*
* @param int $when
* @return int DOS Timestamp
*/
final protected static function dosTime(int $when): int
{
// get date array for timestamp
$d = getdate($when);
// set lower-bound on dates
if ($d['year'] < 1980) {
$d = array(
'year' => 1980,
'mon' => 1,
'mday' => 1,
'hours' => 0,
'minutes' => 0,
'seconds' => 0
);
}
// remove extra years from 1980
$d['year'] -= 1980;
// return date string
return
($d['year'] << 25) |
($d['mon'] << 21) |
($d['mday'] << 16) |
($d['hours'] << 11) |
($d['minutes'] << 5) |
($d['seconds'] >> 1);
}
protected function buildZip64ExtraBlock(bool $force = false): string
{
$fields = [];
if ($this->len->isOver32($force)) {
$fields[] = ['P', $this->len]; // Length of original data
}
if ($this->len->isOver32($force)) {
$fields[] = ['P', $this->zlen]; // Length of compressed data
}
if ($this->ofs->isOver32()) {
$fields[] = ['P', $this->ofs]; // Offset of local header record
}
if (!empty($fields)) {
if (!$this->zip->opt->isEnableZip64()) {
throw new OverflowException();
}
array_unshift(
$fields,
['v', 0x0001], // 64 bit extension
['v', count($fields) * 8] // Length of data block
);
$this->version = Version::ZIP64();
}
return ZipStream::packFields($fields);
}
/**
* Create and send data descriptor footer for this file.
*
* @return void
*/
public function addFileFooter(): void
{
if ($this->bits & self::BIT_ZERO_HEADER) {
// compressed and uncompressed size
$sizeFormat = 'V';
if ($this->zip->opt->isEnableZip64()) {
$sizeFormat = 'P';
}
$fields = [
['V', ZipStream::DATA_DESCRIPTOR_SIGNATURE],
['V', $this->crc], // CRC32
[$sizeFormat, $this->zlen], // Length of compressed data
[$sizeFormat, $this->len], // Length of original data
];
$footer = ZipStream::packFields($fields);
$this->zip->send($footer);
} else {
$footer = '';
}
$this->totalLength = $this->hlen->add($this->zlen)->add(Bigint::init(strlen($footer)));
$this->zip->addToCdr($this);
}
public function processStream(StreamInterface $stream): void
{
$this->zlen = new Bigint();
$this->len = new Bigint();
if ($this->zip->opt->isZeroHeader()) {
$this->processStreamWithZeroHeader($stream);
} else {
$this->processStreamWithComputedHeader($stream);
}
}
protected function processStreamWithZeroHeader(StreamInterface $stream): void
{
$this->bits |= self::BIT_ZERO_HEADER;
$this->addFileHeader();
$this->readStream($stream, self::COMPUTE | self::SEND);
$this->addFileFooter();
}
protected function readStream(StreamInterface $stream, ?int $options = null): void
{
$this->deflateInit();
$total = 0;
$size = $this->opt->getSize();
while (!$stream->eof() && ($size === 0 || $total < $size)) {
$data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
$total += strlen($data);
if ($size > 0 && $total > $size) {
$data = substr($data, 0 , strlen($data)-($total - $size));
}
$this->deflateData($stream, $data, $options);
if ($options & self::SEND) {
$this->zip->send($data);
}
}
$this->deflateFinish($options);
}
protected function deflateInit(): void
{
$this->hash = hash_init(self::HASH_ALGORITHM);
if ($this->method->equals(Method::DEFLATE())) {
$this->deflate = deflate_init(
ZLIB_ENCODING_RAW,
['level' => $this->opt->getDeflateLevel()]
);
}
}
protected function deflateData(StreamInterface $stream, string &$data, ?int $options = null): void
{
if ($options & self::COMPUTE) {
$this->len = $this->len->add(Bigint::init(strlen($data)));
hash_update($this->hash, $data);
}
if ($this->deflate) {
$data = deflate_add(
$this->deflate,
$data,
$stream->eof()
? ZLIB_FINISH
: ZLIB_NO_FLUSH
);
}
if ($options & self::COMPUTE) {
$this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
}
}
protected function deflateFinish(?int $options = null): void
{
if ($options & self::COMPUTE) {
$this->crc = hexdec(hash_final($this->hash));
}
}
protected function processStreamWithComputedHeader(StreamInterface $stream): void
{
$this->readStream($stream, self::COMPUTE);
$stream->rewind();
// incremental compression with deflate_add
// makes this second read unnecessary
// but it is only available from PHP 7.0
if (!$this->deflate && $stream instanceof DeflateStream && $this->method->equals(Method::DEFLATE())) {
$stream->addDeflateFilter($this->opt);
$this->zlen = new Bigint();
while (!$stream->eof()) {
$data = $stream->read(self::CHUNKED_READ_BLOCK_SIZE);
$this->zlen = $this->zlen->add(Bigint::init(strlen($data)));
}
$stream->rewind();
}
$this->addFileHeader();
$this->readStream($stream, self::SEND);
$this->addFileFooter();
}
/**
* Send CDR record for specified file.
*
* @return string
*/
public function getCdrFile(): string
{
$name = static::filterFilename($this->name);
// get attributes
$comment = $this->opt->getComment();
// get dos timestamp
$time = static::dosTime($this->opt->getTime()->getTimestamp());
$footer = $this->buildZip64ExtraBlock();
$fields = [
['V', ZipStream::CDR_FILE_SIGNATURE], // Central file header signature
['v', ZipStream::ZIP_VERSION_MADE_BY], // Made by version
['v', $this->version->getValue()], // Extract by version
['v', $this->bits], // General purpose bit flags - data descriptor flag set
['v', $this->method->getValue()], // Compression method
['V', $time], // Timestamp (DOS Format)
['V', $this->crc], // CRC32
['V', $this->zlen->getLowFF()], // Compressed Data Length
['V', $this->len->getLowFF()], // Original Data Length
['v', strlen($name)], // Length of filename
['v', strlen($footer)], // Extra data len (see above)
['v', strlen($comment)], // Length of comment
['v', 0], // Disk number
['v', 0], // Internal File Attributes
['V', 32], // External File Attributes
['V', $this->ofs->getLowFF()] // Relative offset of local header
];
// pack fields, then append name and comment
$header = ZipStream::packFields($fields);
return $header . $name . $footer . $comment;
}
/**
* @return Bigint
*/
public function getTotalLength(): Bigint
{
return $this->totalLength;
}
}

View File

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace ZipStream\Option;
final class Archive
{
const DEFAULT_DEFLATE_LEVEL = 6;
/**
* @var string
*/
private $comment = '';
/**
* Size, in bytes, of the largest file to try
* and load into memory (used by
* addFileFromPath()). Large files may also
* be compressed differently; see the
* 'largeFileMethod' option. Default is ~20 Mb.
*
* @var int
*/
private $largeFileSize = 20 * 1024 * 1024;
/**
* How to handle large files. Legal values are
* Method::STORE() (the default), or
* Method::DEFLATE(). STORE sends the file
* raw and is significantly
* faster, while DEFLATE compresses the file
* and is much, much slower. Note that DEFLATE
* must compress the file twice and is extremely slow.
*
* @var Method
*/
private $largeFileMethod;
/**
* Boolean indicating whether or not to send
* the HTTP headers for this file.
*
* @var bool
*/
private $sendHttpHeaders = false;
/**
* The method called to send headers
*
* @var Callable
*/
private $httpHeaderCallback = 'header';
/**
* Enable Zip64 extension, supporting very large
* archives (any size > 4 GB or file count > 64k)
*
* @var bool
*/
private $enableZip64 = true;
/**
* Enable streaming files with single read where
* general purpose bit 3 indicates local file header
* contain zero values in crc and size fields,
* these appear only after file contents
* in data descriptor block.
*
* @var bool
*/
private $zeroHeader = false;
/**
* Enable reading file stat for determining file size.
* When a 32-bit system reads file size that is
* over 2 GB, invalid value appears in file size
* due to integer overflow. Should be disabled on
* 32-bit systems with method addFileFromPath
* if any file may exceed 2 GB. In this case file
* will be read in blocks and correct size will be
* determined from content.
*
* @var bool
*/
private $statFiles = true;
/**
* Enable flush after every write to output stream.
* @var bool
*/
private $flushOutput = false;
/**
* HTTP Content-Disposition. Defaults to
* 'attachment', where
* FILENAME is the specified filename.
*
* Note that this does nothing if you are
* not sending HTTP headers.
*
* @var string
*/
private $contentDisposition = 'attachment';
/**
* Note that this does nothing if you are
* not sending HTTP headers.
*
* @var string
*/
private $contentType = 'application/x-zip';
/**
* @var int
*/
private $deflateLevel = 6;
/**
* @var resource
*/
private $outputStream;
/**
* Options constructor.
*/
public function __construct()
{
$this->largeFileMethod = Method::STORE();
$this->outputStream = fopen('php://output', 'wb');
}
public function getComment(): string
{
return $this->comment;
}
public function setComment(string $comment): void
{
$this->comment = $comment;
}
public function getLargeFileSize(): int
{
return $this->largeFileSize;
}
public function setLargeFileSize(int $largeFileSize): void
{
$this->largeFileSize = $largeFileSize;
}
public function getLargeFileMethod(): Method
{
return $this->largeFileMethod;
}
public function setLargeFileMethod(Method $largeFileMethod): void
{
$this->largeFileMethod = $largeFileMethod;
}
public function isSendHttpHeaders(): bool
{
return $this->sendHttpHeaders;
}
public function setSendHttpHeaders(bool $sendHttpHeaders): void
{
$this->sendHttpHeaders = $sendHttpHeaders;
}
public function getHttpHeaderCallback(): Callable
{
return $this->httpHeaderCallback;
}
public function setHttpHeaderCallback(Callable $httpHeaderCallback): void
{
$this->httpHeaderCallback = $httpHeaderCallback;
}
public function isEnableZip64(): bool
{
return $this->enableZip64;
}
public function setEnableZip64(bool $enableZip64): void
{
$this->enableZip64 = $enableZip64;
}
public function isZeroHeader(): bool
{
return $this->zeroHeader;
}
public function setZeroHeader(bool $zeroHeader): void
{
$this->zeroHeader = $zeroHeader;
}
public function isFlushOutput(): bool
{
return $this->flushOutput;
}
public function setFlushOutput(bool $flushOutput): void
{
$this->flushOutput = $flushOutput;
}
public function isStatFiles(): bool
{
return $this->statFiles;
}
public function setStatFiles(bool $statFiles): void
{
$this->statFiles = $statFiles;
}
public function getContentDisposition(): string
{
return $this->contentDisposition;
}
public function setContentDisposition(string $contentDisposition): void
{
$this->contentDisposition = $contentDisposition;
}
public function getContentType(): string
{
return $this->contentType;
}
public function setContentType(string $contentType): void
{
$this->contentType = $contentType;
}
/**
* @return resource
*/
public function getOutputStream()
{
return $this->outputStream;
}
/**
* @param resource $outputStream
*/
public function setOutputStream($outputStream): void
{
$this->outputStream = $outputStream;
}
/**
* @return int
*/
public function getDeflateLevel(): int
{
return $this->deflateLevel;
}
/**
* @param int $deflateLevel
*/
public function setDeflateLevel(int $deflateLevel): void
{
$this->deflateLevel = $deflateLevel;
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace ZipStream\Option;
use DateTime;
final class File
{
/**
* @var string
*/
private $comment = '';
/**
* @var Method
*/
private $method;
/**
* @var int
*/
private $deflateLevel;
/**
* @var DateTime
*/
private $time;
/**
* @var int
*/
private $size = 0;
public function defaultTo(Archive $archiveOptions): void
{
$this->deflateLevel = $this->deflateLevel ?: $archiveOptions->getDeflateLevel();
$this->time = $this->time ?: new DateTime();
}
/**
* @return string
*/
public function getComment(): string
{
return $this->comment;
}
/**
* @param string $comment
*/
public function setComment(string $comment): void
{
$this->comment = $comment;
}
/**
* @return Method
*/
public function getMethod(): Method
{
return $this->method ?: Method::DEFLATE();
}
/**
* @param Method $method
*/
public function setMethod(Method $method): void
{
$this->method = $method;
}
/**
* @return int
*/
public function getDeflateLevel(): int
{
return $this->deflateLevel ?: Archive::DEFAULT_DEFLATE_LEVEL;
}
/**
* @param int $deflateLevel
*/
public function setDeflateLevel(int $deflateLevel): void
{
$this->deflateLevel = $deflateLevel;
}
/**
* @return DateTime
*/
public function getTime(): DateTime
{
return $this->time;
}
/**
* @param DateTime $time
*/
public function setTime(DateTime $time): void
{
$this->time = $time;
}
/**
* @return int
*/
public function getSize(): int
{
return $this->size;
}
/**
* @param int $size
*/
public function setSize(int $size): void
{
$this->size = $size;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace ZipStream\Option;
use MyCLabs\Enum\Enum;
/**
* Methods enum
*
* @method static STORE(): Method
* @method static DEFLATE(): Method
* @psalm-immutable
*/
class Method extends Enum
{
const STORE = 0x00;
const DEFLATE = 0x08;
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ZipStream\Option;
use MyCLabs\Enum\Enum;
/**
* Class Version
* @package ZipStream\Option
*
* @method static STORE(): Version
* @method static DEFLATE(): Version
* @method static ZIP64(): Version
* @psalm-immutable
*/
class Version extends Enum
{
const STORE = 0x000A; // 1.00
const DEFLATE = 0x0014; // 2.00
const ZIP64 = 0x002D; // 4.50
}

View File

@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace ZipStream;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
/**
* Describes a data stream.
*
* Typically, an instance will wrap a PHP stream; this interface provides
* a wrapper around the most common operations, including serialization of
* the entire stream to a string.
*/
class Stream implements StreamInterface
{
protected $stream;
public function __construct($stream)
{
$this->stream = $stream;
}
/**
* Closes the stream and any underlying resources.
*
* @return void
*/
public function close(): void
{
if (is_resource($this->stream)) {
fclose($this->stream);
}
$this->detach();
}
/**
* Separates any underlying resources from the stream.
*
* After the stream has been detached, the stream is in an unusable state.
*
* @return resource|null Underlying PHP stream, if any
*/
public function detach()
{
$result = $this->stream;
$this->stream = null;
return $result;
}
/**
* Reads all data from the stream into a string, from the beginning to end.
*
* This method MUST attempt to seek to the beginning of the stream before
* reading data and read the stream until the end is reached.
*
* Warning: This could attempt to load a large amount of data into memory.
*
* This method MUST NOT raise an exception in order to conform with PHP's
* string casting operations.
*
* @see http://php.net/manual/en/language.oop5.magic.php#object.tostring
* @return string
*/
public function __toString(): string
{
try {
$this->seek(0);
} catch (\RuntimeException $e) {}
return (string) stream_get_contents($this->stream);
}
/**
* Seek to a position in the stream.
*
* @link http://www.php.net/manual/en/function.fseek.php
* @param int $offset Stream offset
* @param int $whence Specifies how the cursor position will be calculated
* based on the seek offset. Valid values are identical to the built-in
* PHP $whence values for `fseek()`. SEEK_SET: Set position equal to
* offset bytes SEEK_CUR: Set position to current location plus offset
* SEEK_END: Set position to end-of-stream plus offset.
* @throws \RuntimeException on failure.
*/
public function seek($offset, $whence = SEEK_SET): void
{
if (!$this->isSeekable()) {
throw new RuntimeException;
}
if (fseek($this->stream, $offset, $whence) !== 0) {
throw new RuntimeException;
}
}
/**
* Returns whether or not the stream is seekable.
*
* @return bool
*/
public function isSeekable(): bool
{
return (bool)$this->getMetadata('seekable');
}
/**
* Get stream metadata as an associative array or retrieve a specific key.
*
* The keys returned are identical to the keys returned from PHP's
* stream_get_meta_data() function.
*
* @link http://php.net/manual/en/function.stream-get-meta-data.php
* @param string $key Specific metadata to retrieve.
* @return array|mixed|null Returns an associative array if no key is
* provided. Returns a specific key value if a key is provided and the
* value is found, or null if the key is not found.
*/
public function getMetadata($key = null)
{
$metadata = stream_get_meta_data($this->stream);
return $key !== null ? @$metadata[$key] : $metadata;
}
/**
* Get the size of the stream if known.
*
* @return int|null Returns the size in bytes if known, or null if unknown.
*/
public function getSize(): ?int
{
$stats = fstat($this->stream);
return $stats['size'];
}
/**
* Returns the current position of the file read/write pointer
*
* @return int Position of the file pointer
* @throws \RuntimeException on error.
*/
public function tell(): int
{
$position = ftell($this->stream);
if ($position === false) {
throw new RuntimeException;
}
return $position;
}
/**
* Returns true if the stream is at the end of the stream.
*
* @return bool
*/
public function eof(): bool
{
return feof($this->stream);
}
/**
* Seek to the beginning of the stream.
*
* If the stream is not seekable, this method will raise an exception;
* otherwise, it will perform a seek(0).
*
* @see seek()
* @link http://www.php.net/manual/en/function.fseek.php
* @throws \RuntimeException on failure.
*/
public function rewind(): void
{
$this->seek(0);
}
/**
* Write data to the stream.
*
* @param string $string The string that is to be written.
* @return int Returns the number of bytes written to the stream.
* @throws \RuntimeException on failure.
*/
public function write($string): int
{
if (!$this->isWritable()) {
throw new RuntimeException;
}
if (fwrite($this->stream, $string) === false) {
throw new RuntimeException;
}
return \mb_strlen($string);
}
/**
* Returns whether or not the stream is writable.
*
* @return bool
*/
public function isWritable(): bool
{
return preg_match('/[waxc+]/', $this->getMetadata('mode')) === 1;
}
/**
* Read data from the stream.
*
* @param int $length Read up to $length bytes from the object and return
* them. Fewer than $length bytes may be returned if underlying stream
* call returns fewer bytes.
* @return string Returns the data read from the stream, or an empty string
* if no bytes are available.
* @throws \RuntimeException if an error occurs.
*/
public function read($length): string
{
if (!$this->isReadable()) {
throw new RuntimeException;
}
$result = fread($this->stream, $length);
if ($result === false) {
throw new RuntimeException;
}
return $result;
}
/**
* Returns whether or not the stream is readable.
*
* @return bool
*/
public function isReadable(): bool
{
return preg_match('/[r+]/', $this->getMetadata('mode')) === 1;
}
/**
* Returns the remaining contents in a string
*
* @return string
* @throws \RuntimeException if unable to read or an error occurs while
* reading.
*/
public function getContents(): string
{
if (!$this->isReadable()) {
throw new RuntimeException;
}
$result = stream_get_contents($this->stream);
if ($result === false) {
throw new RuntimeException;
}
return $result;
}
}

View File

@ -0,0 +1,599 @@
<?php
declare(strict_types=1);
namespace ZipStream;
use Psr\Http\Message\StreamInterface;
use ZipStream\Exception\OverflowException;
use ZipStream\Option\Archive as ArchiveOptions;
use ZipStream\Option\File as FileOptions;
use ZipStream\Option\Version;
/**
* ZipStream
*
* Streamed, dynamically generated zip archives.
*
* Usage:
*
* Streaming zip archives is a simple, three-step process:
*
* 1. Create the zip stream:
*
* $zip = new ZipStream('example.zip');
*
* 2. Add one or more files to the archive:
*
* * add first file
* $data = file_get_contents('some_file.gif');
* $zip->addFile('some_file.gif', $data);
*
* * add second file
* $data = file_get_contents('some_file.gif');
* $zip->addFile('another_file.png', $data);
*
* 3. Finish the zip stream:
*
* $zip->finish();
*
* You can also add an archive comment, add comments to individual files,
* and adjust the timestamp of files. See the API documentation for each
* method below for additional information.
*
* Example:
*
* // create a new zip stream object
* $zip = new ZipStream('some_files.zip');
*
* // list of local files
* $files = array('foo.txt', 'bar.jpg');
*
* // read and add each file to the archive
* foreach ($files as $path)
* $zip->addFile($path, file_get_contents($path));
*
* // write archive footer to stream
* $zip->finish();
*/
class ZipStream
{
/**
* This number corresponds to the ZIP version/OS used (2 bytes)
* From: https://www.iana.org/assignments/media-types/application/zip
* The upper byte (leftmost one) indicates the host system (OS) for the
* file. Software can use this information to determine
* the line record format for text files etc. The current
* mappings are:
*
* 0 - MS-DOS and OS/2 (F.A.T. file systems)
* 1 - Amiga 2 - VAX/VMS
* 3 - *nix 4 - VM/CMS
* 5 - Atari ST 6 - OS/2 H.P.F.S.
* 7 - Macintosh 8 - Z-System
* 9 - CP/M 10 thru 255 - unused
*
* The lower byte (rightmost one) indicates the version number of the
* software used to encode the file. The value/10
* indicates the major version number, and the value
* mod 10 is the minor version number.
* Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
* to prevent file permissions issues upon extract (see #84)
* 0x603 is 00000110 00000011 in binary, so 6 and 3
*/
const ZIP_VERSION_MADE_BY = 0x603;
/**
* The following signatures end with 0x4b50, which in ASCII is PK,
* the initials of the inventor Phil Katz.
* See https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers
*/
const FILE_HEADER_SIGNATURE = 0x04034b50;
const CDR_FILE_SIGNATURE = 0x02014b50;
const CDR_EOF_SIGNATURE = 0x06054b50;
const DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
const ZIP64_CDR_EOF_SIGNATURE = 0x06064b50;
const ZIP64_CDR_LOCATOR_SIGNATURE = 0x07064b50;
/**
* Global Options
*
* @var ArchiveOptions
*/
public $opt;
/**
* @var array
*/
public $files = [];
/**
* @var Bigint
*/
public $cdr_ofs;
/**
* @var Bigint
*/
public $ofs;
/**
* @var bool
*/
protected $need_headers;
/**
* @var null|String
*/
protected $output_name;
/**
* Create a new ZipStream object.
*
* Parameters:
*
* @param String $name - Name of output file (optional).
* @param ArchiveOptions $opt - Archive Options
*
* Large File Support:
*
* By default, the method addFileFromPath() will send send files
* larger than 20 megabytes along raw rather than attempting to
* compress them. You can change both the maximum size and the
* compression behavior using the largeFile* options above, with the
* following caveats:
*
* * For "small" files (e.g. files smaller than largeFileSize), the
* memory use can be up to twice that of the actual file. In other
* words, adding a 10 megabyte file to the archive could potentially
* occupy 20 megabytes of memory.
*
* * Enabling compression on large files (e.g. files larger than
* large_file_size) is extremely slow, because ZipStream has to pass
* over the large file once to calculate header information, and then
* again to compress and send the actual data.
*
* Examples:
*
* // create a new zip file named 'foo.zip'
* $zip = new ZipStream('foo.zip');
*
* // create a new zip file named 'bar.zip' with a comment
* $opt->setComment = 'this is a comment for the zip file.';
* $zip = new ZipStream('bar.zip', $opt);
*
* Notes:
*
* In order to let this library send HTTP headers, a filename must be given
* _and_ the option `sendHttpHeaders` must be `true`. This behavior is to
* allow software to send its own headers (including the filename), and
* still use this library.
*/
public function __construct(?string $name = null, ?ArchiveOptions $opt = null)
{
$this->opt = $opt ?: new ArchiveOptions();
$this->output_name = $name;
$this->need_headers = $name && $this->opt->isSendHttpHeaders();
$this->cdr_ofs = new Bigint();
$this->ofs = new Bigint();
}
/**
* addFile
*
* Add a file to the archive.
*
* @param String $name - path of file in archive (including directory).
* @param String $data - contents of file
* @param FileOptions $options
*
* File Options:
* time - Last-modified timestamp (seconds since the epoch) of
* this file. Defaults to the current time.
* comment - Comment related to this file.
* method - Storage method for file ("store" or "deflate")
*
* Examples:
*
* // add a file named 'foo.txt'
* $data = file_get_contents('foo.txt');
* $zip->addFile('foo.txt', $data);
*
* // add a file named 'bar.jpg' with a comment and a last-modified
* // time of two hours ago
* $data = file_get_contents('bar.jpg');
* $opt->setTime = time() - 2 * 3600;
* $opt->setComment = 'this is a comment about bar.jpg';
* $zip->addFile('bar.jpg', $data, $opt);
*/
public function addFile(string $name, string $data, ?FileOptions $options = null): void
{
$options = $options ?: new FileOptions();
$options->defaultTo($this->opt);
$file = new File($this, $name, $options);
$file->processData($data);
}
/**
* addFileFromPath
*
* Add a file at path to the archive.
*
* Note that large files may be compressed differently than smaller
* files; see the "Large File Support" section above for more
* information.
*
* @param String $name - name of file in archive (including directory path).
* @param String $path - path to file on disk (note: paths should be encoded using
* UNIX-style forward slashes -- e.g '/path/to/some/file').
* @param FileOptions $options
*
* File Options:
* time - Last-modified timestamp (seconds since the epoch) of
* this file. Defaults to the current time.
* comment - Comment related to this file.
* method - Storage method for file ("store" or "deflate")
*
* Examples:
*
* // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
* $zip->addFileFromPath('foo.txt', '/tmp/foo.txt');
*
* // add a file named 'bigfile.rar' from the local file
* // '/usr/share/bigfile.rar' with a comment and a last-modified
* // time of two hours ago
* $path = '/usr/share/bigfile.rar';
* $opt->setTime = time() - 2 * 3600;
* $opt->setComment = 'this is a comment about bar.jpg';
* $zip->addFileFromPath('bigfile.rar', $path, $opt);
*
* @return void
* @throws \ZipStream\Exception\FileNotFoundException
* @throws \ZipStream\Exception\FileNotReadableException
*/
public function addFileFromPath(string $name, string $path, ?FileOptions $options = null): void
{
$options = $options ?: new FileOptions();
$options->defaultTo($this->opt);
$file = new File($this, $name, $options);
$file->processPath($path);
}
/**
* addFileFromStream
*
* Add an open stream to the archive.
*
* @param String $name - path of file in archive (including directory).
* @param resource $stream - contents of file as a stream resource
* @param FileOptions $options
*
* File Options:
* time - Last-modified timestamp (seconds since the epoch) of
* this file. Defaults to the current time.
* comment - Comment related to this file.
*
* Examples:
*
* // create a temporary file stream and write text to it
* $fp = tmpfile();
* fwrite($fp, 'The quick brown fox jumped over the lazy dog.');
*
* // add a file named 'streamfile.txt' from the content of the stream
* $x->addFileFromStream('streamfile.txt', $fp);
*
* @return void
*/
public function addFileFromStream(string $name, $stream, ?FileOptions $options = null): void
{
$options = $options ?: new FileOptions();
$options->defaultTo($this->opt);
$file = new File($this, $name, $options);
$file->processStream(new DeflateStream($stream));
}
/**
* addFileFromPsr7Stream
*
* Add an open stream to the archive.
*
* @param String $name - path of file in archive (including directory).
* @param StreamInterface $stream - contents of file as a stream resource
* @param FileOptions $options
*
* File Options:
* time - Last-modified timestamp (seconds since the epoch) of
* this file. Defaults to the current time.
* comment - Comment related to this file.
*
* Examples:
*
* // create a temporary file stream and write text to it
* $fp = tmpfile();
* fwrite($fp, 'The quick brown fox jumped over the lazy dog.');
*
* // add a file named 'streamfile.txt' from the content of the stream
* $x->addFileFromPsr7Stream('streamfile.txt', $fp);
*
* @return void
*/
public function addFileFromPsr7Stream(
string $name,
StreamInterface $stream,
?FileOptions $options = null
): void {
$options = $options ?: new FileOptions();
$options->defaultTo($this->opt);
$file = new File($this, $name, $options);
$file->processStream($stream);
}
/**
* finish
*
* Write zip footer to stream.
*
* Example:
*
* // add a list of files to the archive
* $files = array('foo.txt', 'bar.jpg');
* foreach ($files as $path)
* $zip->addFile($path, file_get_contents($path));
*
* // write footer to stream
* $zip->finish();
* @return void
*
* @throws OverflowException
*/
public function finish(): void
{
// add trailing cdr file records
foreach ($this->files as $cdrFile) {
$this->send($cdrFile);
$this->cdr_ofs = $this->cdr_ofs->add(Bigint::init(strlen($cdrFile)));
}
// Add 64bit headers (if applicable)
if (count($this->files) >= 0xFFFF ||
$this->cdr_ofs->isOver32() ||
$this->ofs->isOver32()) {
if (!$this->opt->isEnableZip64()) {
throw new OverflowException();
}
$this->addCdr64Eof();
$this->addCdr64Locator();
}
// add trailing cdr eof record
$this->addCdrEof();
// The End
$this->clear();
}
/**
* Send ZIP64 CDR EOF (Central Directory Record End-of-File) record.
*
* @return void
*/
protected function addCdr64Eof(): void
{
$num_files = count($this->files);
$cdr_length = $this->cdr_ofs;
$cdr_offset = $this->ofs;
$fields = [
['V', static::ZIP64_CDR_EOF_SIGNATURE], // ZIP64 end of central file header signature
['P', 44], // Length of data below this header (length of block - 12) = 44
['v', static::ZIP_VERSION_MADE_BY], // Made by version
['v', Version::ZIP64], // Extract by version
['V', 0x00], // disk number
['V', 0x00], // no of disks
['P', $num_files], // no of entries on disk
['P', $num_files], // no of entries in cdr
['P', $cdr_length], // CDR size
['P', $cdr_offset], // CDR offset
];
$ret = static::packFields($fields);
$this->send($ret);
}
/**
* Create a format string and argument list for pack(), then call
* pack() and return the result.
*
* @param array $fields
* @return string
*/
public static function packFields(array $fields): string
{
$fmt = '';
$args = [];
// populate format string and argument list
foreach ($fields as [$format, $value]) {
if ($format === 'P') {
$fmt .= 'VV';
if ($value instanceof Bigint) {
$args[] = $value->getLow32();
$args[] = $value->getHigh32();
} else {
$args[] = $value;
$args[] = 0;
}
} else {
if ($value instanceof Bigint) {
$value = $value->getLow32();
}
$fmt .= $format;
$args[] = $value;
}
}
// prepend format string to argument list
array_unshift($args, $fmt);
// build output string from header and compressed data
return pack(...$args);
}
/**
* Send string, sending HTTP headers if necessary.
* Flush output after write if configure option is set.
*
* @param String $str
* @return void
*/
public function send(string $str): void
{
if ($this->need_headers) {
$this->sendHttpHeaders();
}
$this->need_headers = false;
fwrite($this->opt->getOutputStream(), $str);
if ($this->opt->isFlushOutput()) {
// flush output buffer if it is on and flushable
$status = ob_get_status();
if (isset($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
ob_flush();
}
// Flush system buffers after flushing userspace output buffer
flush();
}
}
/**
* Send HTTP headers for this stream.
*
* @return void
*/
protected function sendHttpHeaders(): void
{
// grab content disposition
$disposition = $this->opt->getContentDisposition();
if ($this->output_name) {
// Various different browsers dislike various characters here. Strip them all for safety.
$safe_output = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->output_name));
// Check if we need to UTF-8 encode the filename
$urlencoded = rawurlencode($safe_output);
$disposition .= "; filename*=UTF-8''{$urlencoded}";
}
$headers = array(
'Content-Type' => $this->opt->getContentType(),
'Content-Disposition' => $disposition,
'Pragma' => 'public',
'Cache-Control' => 'public, must-revalidate',
'Content-Transfer-Encoding' => 'binary'
);
$call = $this->opt->getHttpHeaderCallback();
foreach ($headers as $key => $val) {
$call("$key: $val");
}
}
/**
* Send ZIP64 CDR Locator (Central Directory Record Locator) record.
*
* @return void
*/
protected function addCdr64Locator(): void
{
$cdr_offset = $this->ofs->add($this->cdr_ofs);
$fields = [
['V', static::ZIP64_CDR_LOCATOR_SIGNATURE], // ZIP64 end of central file header signature
['V', 0x00], // Disc number containing CDR64EOF
['P', $cdr_offset], // CDR offset
['V', 1], // Total number of disks
];
$ret = static::packFields($fields);
$this->send($ret);
}
/**
* Send CDR EOF (Central Directory Record End-of-File) record.
*
* @return void
*/
protected function addCdrEof(): void
{
$num_files = count($this->files);
$cdr_length = $this->cdr_ofs;
$cdr_offset = $this->ofs;
// grab comment (if specified)
$comment = $this->opt->getComment();
$fields = [
['V', static::CDR_EOF_SIGNATURE], // end of central file header signature
['v', 0x00], // disk number
['v', 0x00], // no of disks
['v', min($num_files, 0xFFFF)], // no of entries on disk
['v', min($num_files, 0xFFFF)], // no of entries in cdr
['V', $cdr_length->getLowFF()], // CDR size
['V', $cdr_offset->getLowFF()], // CDR offset
['v', strlen($comment)], // Zip Comment size
];
$ret = static::packFields($fields) . $comment;
$this->send($ret);
}
/**
* Clear all internal variables. Note that the stream object is not
* usable after this.
*
* @return void
*/
protected function clear(): void
{
$this->files = [];
$this->ofs = new Bigint();
$this->cdr_ofs = new Bigint();
$this->opt = new ArchiveOptions();
}
/**
* Is this file larger than large_file_size?
*
* @param string $path
* @return bool
*/
public function isLargeFile(string $path): bool
{
if (!$this->opt->isStatFiles()) {
return false;
}
$stat = stat($path);
return $stat['size'] > $this->opt->getLargeFileSize();
}
/**
* Save file attributes for trailing CDR record.
*
* @param File $file
* @return void
*/
public function addToCdr(File $file): void
{
$file->ofs = $this->ofs;
$this->ofs = $this->ofs->add($file->getTotalLength());
$this->files[] = $file->getCdrFile();
}
}