You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
420 lines
13 KiB
420 lines
13 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ZipStream;
|
|
|
|
use Closure;
|
|
use DateTimeInterface;
|
|
use DeflateContext;
|
|
use RuntimeException;
|
|
use ZipStream\Exception\FileSizeIncorrectException;
|
|
use ZipStream\Exception\OverflowException;
|
|
use ZipStream\Exception\ResourceActionException;
|
|
use ZipStream\Exception\SimulationFileUnknownException;
|
|
use ZipStream\Exception\StreamNotReadableException;
|
|
use ZipStream\Exception\StreamNotSeekableException;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class File
|
|
{
|
|
private const CHUNKED_READ_BLOCK_SIZE = 0x1000000;
|
|
|
|
private Version $version;
|
|
|
|
private int $compressedSize = 0;
|
|
|
|
private int $uncompressedSize = 0;
|
|
|
|
private int $crc = 0;
|
|
|
|
private int $generalPurposeBitFlag = 0;
|
|
|
|
private readonly string $fileName;
|
|
|
|
/**
|
|
* @var resource|null
|
|
*/
|
|
private $stream;
|
|
|
|
/**
|
|
* @param Closure $dataCallback
|
|
* @psalm-param Closure(): resource $dataCallback
|
|
*/
|
|
public function __construct(
|
|
string $fileName,
|
|
private readonly Closure $dataCallback,
|
|
private readonly OperationMode $operationMode,
|
|
private readonly int $startOffset,
|
|
private readonly CompressionMethod $compressionMethod,
|
|
private readonly string $comment,
|
|
private readonly DateTimeInterface $lastModificationDateTime,
|
|
private readonly int $deflateLevel,
|
|
private readonly ?int $maxSize,
|
|
private readonly ?int $exactSize,
|
|
private readonly bool $enableZip64,
|
|
private readonly bool $enableZeroHeader,
|
|
private readonly Closure $send,
|
|
private readonly Closure $recordSentBytes,
|
|
) {
|
|
$this->fileName = self::filterFilename($fileName);
|
|
$this->checkEncoding();
|
|
|
|
if ($this->enableZeroHeader) {
|
|
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
|
|
}
|
|
|
|
$this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
|
|
}
|
|
|
|
public function cloneSimulationExecution(): self
|
|
{
|
|
return new self(
|
|
$this->fileName,
|
|
$this->dataCallback,
|
|
OperationMode::NORMAL,
|
|
$this->startOffset,
|
|
$this->compressionMethod,
|
|
$this->comment,
|
|
$this->lastModificationDateTime,
|
|
$this->deflateLevel,
|
|
$this->maxSize,
|
|
$this->exactSize,
|
|
$this->enableZip64,
|
|
$this->enableZeroHeader,
|
|
$this->send,
|
|
$this->recordSentBytes,
|
|
);
|
|
}
|
|
|
|
public function process(): string
|
|
{
|
|
$forecastSize = $this->forecastSize();
|
|
|
|
if ($this->enableZeroHeader) {
|
|
// No calculation required
|
|
} elseif ($this->isSimulation() && $forecastSize) {
|
|
$this->uncompressedSize = $forecastSize;
|
|
$this->compressedSize = $forecastSize;
|
|
} else {
|
|
$this->readStream(send: false);
|
|
if (rewind($this->unpackStream()) === false) {
|
|
throw new ResourceActionException('rewind', $this->unpackStream());
|
|
}
|
|
}
|
|
|
|
$this->addFileHeader();
|
|
|
|
$detectedSize = $forecastSize ?? $this->compressedSize;
|
|
|
|
if (
|
|
$this->isSimulation() &&
|
|
$detectedSize > 0
|
|
) {
|
|
($this->recordSentBytes)($detectedSize);
|
|
} else {
|
|
$this->readStream(send: true);
|
|
}
|
|
|
|
$this->addFileFooter();
|
|
return $this->getCdrFile();
|
|
}
|
|
|
|
/**
|
|
* @return resource
|
|
*/
|
|
private function unpackStream()
|
|
{
|
|
if ($this->stream) {
|
|
return $this->stream;
|
|
}
|
|
|
|
if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
|
|
throw new SimulationFileUnknownException();
|
|
}
|
|
|
|
$this->stream = ($this->dataCallback)();
|
|
|
|
if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
|
|
throw new StreamNotSeekableException();
|
|
}
|
|
if (!(
|
|
str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
|
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
|
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
|
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
|
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
|
|
)) {
|
|
throw new StreamNotReadableException();
|
|
}
|
|
|
|
return $this->stream;
|
|
}
|
|
|
|
private function forecastSize(): ?int
|
|
{
|
|
if ($this->compressionMethod !== CompressionMethod::STORE) {
|
|
return null;
|
|
}
|
|
if ($this->exactSize) {
|
|
return $this->exactSize;
|
|
}
|
|
$fstat = fstat($this->unpackStream());
|
|
if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
|
|
return $this->maxSize;
|
|
}
|
|
|
|
return $fstat['size'];
|
|
}
|
|
|
|
/**
|
|
* Create and send zip header for this file.
|
|
*/
|
|
private function addFileHeader(): void
|
|
{
|
|
$forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
|
|
|
|
$footer = $this->buildZip64ExtraBlock($forceEnableZip64);
|
|
|
|
$zip64Enabled = $footer !== '';
|
|
|
|
if($zip64Enabled) {
|
|
$this->version = Version::ZIP64;
|
|
}
|
|
|
|
if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
|
|
// Put the tricky entry to
|
|
// force Linux unzip to lookup EFS flag.
|
|
$footer .= Zs\ExtendedInformationExtraField::generate();
|
|
}
|
|
|
|
$data = LocalFileHeader::generate(
|
|
versionNeededToExtract: $this->version->value,
|
|
generalPurposeBitFlag: $this->generalPurposeBitFlag,
|
|
compressionMethod: $this->compressionMethod,
|
|
lastModificationDateTime: $this->lastModificationDateTime,
|
|
crc32UncompressedData: $this->crc,
|
|
compressedSize: $zip64Enabled
|
|
? 0xFFFFFFFF
|
|
: $this->compressedSize,
|
|
uncompressedSize: $zip64Enabled
|
|
? 0xFFFFFFFF
|
|
: $this->uncompressedSize,
|
|
fileName: $this->fileName,
|
|
extraField: $footer,
|
|
);
|
|
|
|
|
|
($this->send)($data);
|
|
}
|
|
|
|
/**
|
|
* Strip characters that are not legal in Windows filenames
|
|
* to prevent compatibility issues
|
|
*/
|
|
private static function filterFilename(
|
|
/**
|
|
* Unprocessed filename
|
|
*/
|
|
string $fileName
|
|
): string {
|
|
// strip leading slashes from file name
|
|
// (fixes bug in windows archive viewer)
|
|
$fileName = ltrim($fileName, '/');
|
|
|
|
return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
|
|
}
|
|
|
|
private function checkEncoding(): void
|
|
{
|
|
// 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($this->fileName, 'UTF-8') &&
|
|
mb_check_encoding($this->comment, 'UTF-8')) {
|
|
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
|
|
}
|
|
}
|
|
|
|
private function buildZip64ExtraBlock(bool $force = false): string
|
|
{
|
|
$outputZip64ExtraBlock = false;
|
|
|
|
$originalSize = null;
|
|
if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
|
|
$outputZip64ExtraBlock = true;
|
|
$originalSize = $this->uncompressedSize;
|
|
}
|
|
|
|
$compressedSize = null;
|
|
if ($force || $this->compressedSize > 0xFFFFFFFF) {
|
|
$outputZip64ExtraBlock = true;
|
|
$compressedSize = $this->compressedSize;
|
|
}
|
|
|
|
// 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
|
|
$relativeHeaderOffset = null;
|
|
if ($this->startOffset > 0xFFFFFFFF) {
|
|
$outputZip64ExtraBlock = true;
|
|
$relativeHeaderOffset = $this->startOffset;
|
|
}
|
|
|
|
if (!$outputZip64ExtraBlock) {
|
|
return '';
|
|
}
|
|
|
|
if (!$this->enableZip64) {
|
|
throw new OverflowException();
|
|
}
|
|
|
|
return Zip64\ExtendedInformationExtraField::generate(
|
|
originalSize: $originalSize,
|
|
compressedSize: $compressedSize,
|
|
relativeHeaderOffset: $relativeHeaderOffset,
|
|
diskStartNumber: null,
|
|
);
|
|
}
|
|
|
|
private function addFileFooter(): void
|
|
{
|
|
if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
|
|
throw new OverflowException();
|
|
}
|
|
|
|
if (!$this->enableZeroHeader) {
|
|
return;
|
|
}
|
|
|
|
if ($this->version === Version::ZIP64) {
|
|
$footer = Zip64\DataDescriptor::generate(
|
|
crc32UncompressedData: $this->crc,
|
|
compressedSize: $this->compressedSize,
|
|
uncompressedSize: $this->uncompressedSize,
|
|
);
|
|
} else {
|
|
$footer = DataDescriptor::generate(
|
|
crc32UncompressedData: $this->crc,
|
|
compressedSize: $this->compressedSize,
|
|
uncompressedSize: $this->uncompressedSize,
|
|
);
|
|
}
|
|
|
|
($this->send)($footer);
|
|
}
|
|
|
|
private function readStream(bool $send): void
|
|
{
|
|
$this->compressedSize = 0;
|
|
$this->uncompressedSize = 0;
|
|
$hash = hash_init('crc32b');
|
|
|
|
$deflate = $this->compressionInit();
|
|
|
|
while (
|
|
!feof($this->unpackStream()) &&
|
|
($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
|
|
($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
|
|
) {
|
|
$readLength = min(
|
|
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
|
($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
|
self::CHUNKED_READ_BLOCK_SIZE
|
|
);
|
|
|
|
$data = fread($this->unpackStream(), $readLength);
|
|
|
|
hash_update($hash, $data);
|
|
|
|
$this->uncompressedSize += strlen($data);
|
|
|
|
if ($deflate) {
|
|
$data = deflate_add(
|
|
$deflate,
|
|
$data,
|
|
feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
|
|
);
|
|
}
|
|
|
|
$this->compressedSize += strlen($data);
|
|
|
|
if ($send) {
|
|
($this->send)($data);
|
|
}
|
|
}
|
|
|
|
if ($this->exactSize && $this->uncompressedSize !== $this->exactSize) {
|
|
throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
|
|
}
|
|
|
|
$this->crc = hexdec(hash_final($hash));
|
|
}
|
|
|
|
private function compressionInit(): ?DeflateContext
|
|
{
|
|
switch($this->compressionMethod) {
|
|
case CompressionMethod::STORE:
|
|
// Noting to do
|
|
return null;
|
|
case CompressionMethod::DEFLATE:
|
|
$deflateContext = deflate_init(
|
|
ZLIB_ENCODING_RAW,
|
|
['level' => $this->deflateLevel]
|
|
);
|
|
|
|
if (!$deflateContext) {
|
|
// @codeCoverageIgnoreStart
|
|
throw new RuntimeException("Can't initialize deflate context.");
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
|
|
// False positive, resource is no longer returned from this function
|
|
return $deflateContext;
|
|
default:
|
|
// @codeCoverageIgnoreStart
|
|
throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
}
|
|
|
|
private function getCdrFile(): string
|
|
{
|
|
$footer = $this->buildZip64ExtraBlock();
|
|
|
|
return CentralDirectoryFileHeader::generate(
|
|
versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
|
|
versionNeededToExtract:$this->version->value,
|
|
generalPurposeBitFlag: $this->generalPurposeBitFlag,
|
|
compressionMethod: $this->compressionMethod,
|
|
lastModificationDateTime: $this->lastModificationDateTime,
|
|
crc32: $this->crc,
|
|
compressedSize: $this->compressedSize > 0xFFFFFFFF
|
|
? 0xFFFFFFFF
|
|
: $this->compressedSize,
|
|
uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
|
|
? 0xFFFFFFFF
|
|
: $this->uncompressedSize,
|
|
fileName: $this->fileName,
|
|
extraField: $footer,
|
|
fileComment: $this->comment,
|
|
diskNumberStart: 0,
|
|
internalFileAttributes: 0,
|
|
externalFileAttributes: 32,
|
|
relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
|
|
? 0xFFFFFFFF
|
|
: $this->startOffset,
|
|
);
|
|
}
|
|
|
|
private function isSimulation(): bool
|
|
{
|
|
return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
|
|
}
|
|
}
|
|
|