471 lines
14 KiB
PHP
471 lines
14 KiB
PHP
|
<?php
|
||
|
|
||
|
declare(strict_types=1);
|
||
|
|
||
|
namespace ZipStream;
|
||
|
|
||
|
use HashContext;
|
||
|
use Psr\Http\Message\StreamInterface;
|
||
|
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
|
||
|
{
|
||
|
public const HASH_ALGORITHM = 'crc32b';
|
||
|
|
||
|
public const BIT_ZERO_HEADER = 0x0008;
|
||
|
|
||
|
public const BIT_EFS_UTF8 = 0x0800;
|
||
|
|
||
|
public const COMPUTE = 1;
|
||
|
|
||
|
public 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 HashContext
|
||
|
*/
|
||
|
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 Stream(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')) {
|
||
|
$this->bits |= self::BIT_EFS_UTF8;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($this->method->equals(Method::DEFLATE())) {
|
||
|
$this->version = Version::DEFLATE();
|
||
|
}
|
||
|
|
||
|
$force = (bool)($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);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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 = [
|
||
|
'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();
|
||
|
}
|
||
|
|
||
|
if ($this->bits & self::BIT_EFS_UTF8) {
|
||
|
// Put the tricky entry to
|
||
|
// force Linux unzip to lookup EFS flag.
|
||
|
$fields[] = ['v', 0x5653]; // Choose 'ZS' for proprietary usage
|
||
|
$fields[] = ['v', 0x0000]; // zero length
|
||
|
}
|
||
|
|
||
|
return ZipStream::packFields($fields);
|
||
|
}
|
||
|
|
||
|
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
|
||
|
{
|
||
|
$hash = hash_init(self::HASH_ALGORITHM);
|
||
|
$this->hash = $hash;
|
||
|
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();
|
||
|
|
||
|
$this->addFileHeader();
|
||
|
$this->readStream($stream, self::SEND);
|
||
|
$this->addFileFooter();
|
||
|
}
|
||
|
}
|