Skip to content

Commit 0dcf9d4

Browse files
committed
Calculate ZIP Size
Calculates the resulting file size with the minimal amount of work that is required. Fixes #89
1 parent 494ce19 commit 0dcf9d4

File tree

7 files changed

+844
-86
lines changed

7 files changed

+844
-86
lines changed

psalm.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@
1818
<!-- Turn off dead code warnings for externally called functions -->
1919
<PossiblyUnusedProperty errorLevel="suppress" />
2020
<PossiblyUnusedMethod errorLevel="suppress" />
21+
<PossiblyUnusedReturnValue errorLevel="suppress" />
2122
</issueHandlers>
2223
</psalm>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ZipStream\Exception;
6+
7+
use ZipStream\Exception;
8+
9+
/**
10+
* This Exception gets invoked if a file is not as large as it was specified.
11+
*/
12+
class FileSizeIncorrectException extends Exception
13+
{
14+
/**
15+
* @internal
16+
*/
17+
public function __construct(
18+
public readonly int $expectedSize,
19+
public readonly int $actualSize
20+
) {
21+
parent::__construct("File is {$actualSize} instead of {$expectedSize} bytes large. Adjust `exactSize` parameter.");
22+
}
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ZipStream\Exception;
6+
7+
use ZipStream\Exception;
8+
9+
/**
10+
* This Exception gets invoked if a strict simulation is executed and the file
11+
* information can't be determined without reading the entire file.
12+
*/
13+
class SimulationFileUnknownException extends Exception
14+
{
15+
public function __construct()
16+
{
17+
parent::__construct('The details of the strict simulation file could not be determined without reading the entire file.');
18+
}
19+
}

src/File.php

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
use DateTimeInterface;
99
use DeflateContext;
1010
use RuntimeException;
11+
use ZipStream\Exception\FileSizeIncorrectException;
1112
use ZipStream\Exception\OverflowException;
1213
use ZipStream\Exception\ResourceActionException;
14+
use ZipStream\Exception\SimulationFileUnknownException;
1315
use ZipStream\Exception\StreamNotReadableException;
1416
use ZipStream\Exception\StreamNotSeekableException;
1517

@@ -32,28 +34,30 @@ class File
3234

3335
private readonly string $fileName;
3436

35-
private int $totalSize = 0;
36-
3737
/**
38-
* @var resource
38+
* @var resource|null
3939
*/
4040
private $stream;
4141

4242
/**
43-
* @param resource $stream
43+
* @param Closure $dataCallback
44+
* @psalm-param Closure(): resource $dataCallback
4445
*/
4546
public function __construct(
4647
string $fileName,
47-
private int $startOffset,
48+
private readonly Closure $dataCallback,
49+
private readonly OperationMode $operationMode,
50+
private readonly int $startOffset,
4851
private readonly CompressionMethod $compressionMethod,
4952
private readonly string $comment,
5053
private readonly DateTimeInterface $lastModificationDateTime,
5154
private readonly int $deflateLevel,
5255
private readonly ?int $maxSize,
56+
private readonly ?int $exactSize,
5357
private readonly bool $enableZip64,
5458
private readonly bool $enableZeroHeader,
5559
private readonly Closure $send,
56-
$stream,
60+
private readonly Closure $recordSentBytes,
5761
) {
5862
$this->fileName = self::filterFilename($fileName);
5963
$this->checkEncoding();
@@ -63,36 +67,110 @@ public function __construct(
6367
}
6468

6569
$this->selectVersion();
70+
}
71+
72+
public function cloneSimulationExecution(): self
73+
{
74+
return new self(
75+
$this->fileName,
76+
$this->dataCallback,
77+
OperationMode::NORMAL,
78+
$this->startOffset,
79+
$this->compressionMethod,
80+
$this->comment,
81+
$this->lastModificationDateTime,
82+
$this->deflateLevel,
83+
$this->maxSize,
84+
$this->exactSize,
85+
$this->enableZip64,
86+
$this->enableZeroHeader,
87+
$this->send,
88+
$this->recordSentBytes,
89+
);
90+
}
91+
92+
public function process(): string
93+
{
94+
$forecastSize = $this->forecastSize();
95+
96+
if ($this->enableZeroHeader) {
97+
// No calculation required
98+
} elseif ($this->isSimulation() && $forecastSize) {
99+
$this->uncompressedSize = $forecastSize;
100+
$this->compressedSize = $forecastSize;
101+
} else {
102+
$this->readStream(send: false);
103+
if (rewind($this->unpackStream()) === false) {
104+
throw new ResourceActionException('rewind', $this->unpackStream());
105+
}
106+
}
107+
108+
$this->addFileHeader();
109+
110+
$detectedSize = $forecastSize ?? $this->compressedSize;
111+
112+
if (
113+
$this->isSimulation() &&
114+
$detectedSize > 0
115+
) {
116+
($this->recordSentBytes)($detectedSize);
117+
} else {
118+
$this->readStream(send: true);
119+
}
120+
121+
$this->addFileFooter();
122+
return $this->getCdrFile();
123+
}
124+
125+
/**
126+
* @return resource
127+
*/
128+
private function unpackStream()
129+
{
130+
if ($this->stream) {
131+
return $this->stream;
132+
}
133+
134+
if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
135+
throw new SimulationFileUnknownException();
136+
}
137+
138+
$this->stream = ($this->dataCallback)();
66139

67-
if (!$this->enableZeroHeader && !stream_get_meta_data($stream)['seekable']) {
140+
if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
68141
throw new StreamNotSeekableException();
69142
}
70143
if (!(
71-
str_contains(stream_get_meta_data($stream)['mode'], 'r')
72-
|| str_contains(stream_get_meta_data($stream)['mode'], 'w+')
73-
|| str_contains(stream_get_meta_data($stream)['mode'], 'a+')
74-
|| str_contains(stream_get_meta_data($stream)['mode'], 'x+')
75-
|| str_contains(stream_get_meta_data($stream)['mode'], 'c+')
144+
str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
145+
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
146+
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
147+
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
148+
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
76149
)) {
77150
throw new StreamNotReadableException();
78151
}
79-
$this->stream = $stream;
152+
153+
return $this->stream;
80154
}
81155

82-
public function process(): string
156+
private function forecastSize(): ?int
83157
{
84-
if (!$this->enableZeroHeader) {
85-
$this->readStream(send: false);
86-
if (rewind($this->stream) === false) {
87-
throw new ResourceActionException('rewind', $this->stream);
88-
}
158+
if ($this->compressionMethod !== CompressionMethod::STORE) {
159+
return null;
160+
}
161+
if ($this->exactSize) {
162+
return $this->exactSize;
163+
}
164+
$fstat = fstat($this->unpackStream());
165+
if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
166+
return null;
89167
}
90168

91-
$this->addFileHeader();
92-
$this->readStream(send: true);
93-
$this->addFileFooter();
169+
if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
170+
return $this->maxSize;
171+
}
94172

95-
return $this->getCdrFile();
173+
return $fstat['size'];
96174
}
97175

98176
/**
@@ -127,8 +205,6 @@ private function addFileHeader(): void
127205

128206

129207
($this->send)($data);
130-
131-
$this->totalSize += strlen($data);
132208
}
133209

134210
/**
@@ -239,8 +315,6 @@ private function addFileFooter(): void
239315
}
240316

241317
($this->send)($footer);
242-
243-
$this->totalSize += strlen($footer);
244318
}
245319

246320
private function readStream(bool $send): void
@@ -251,10 +325,18 @@ private function readStream(bool $send): void
251325

252326
$deflate = $this->compressionInit();
253327

254-
while (!feof($this->stream) && ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)) {
255-
$readLength = min(($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize, self::CHUNKED_READ_BLOCK_SIZE);
328+
while (
329+
!feof($this->unpackStream()) &&
330+
($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
331+
($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
332+
) {
333+
$readLength = min(
334+
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
335+
($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
336+
self::CHUNKED_READ_BLOCK_SIZE
337+
);
256338

257-
$data = fread($this->stream, $readLength);
339+
$data = fread($this->unpackStream(), $readLength);
258340

259341
hash_update($hash, $data);
260342

@@ -264,18 +346,21 @@ private function readStream(bool $send): void
264346
$data = deflate_add(
265347
$deflate,
266348
$data,
267-
feof($this->stream) ? ZLIB_FINISH : ZLIB_NO_FLUSH
349+
feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
268350
);
269351
}
270352

271353
$this->compressedSize += strlen($data);
272354

273355
if ($send) {
274356
($this->send)($data);
275-
$this->totalSize += strlen($data);
276357
}
277358
}
278359

360+
if ($this->exactSize && $this->uncompressedSize !== $this->exactSize) {
361+
throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
362+
}
363+
279364
$this->crc = hexdec(hash_final($hash));
280365
}
281366

@@ -334,4 +419,9 @@ private function getCdrFile(): string
334419
: $this->startOffset,
335420
);
336421
}
422+
423+
private function isSimulation(): bool
424+
{
425+
return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
426+
}
337427
}

src/OperationMode.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ZipStream;
6+
7+
/**
8+
* ZipStream execution operation modes
9+
*/
10+
enum OperationMode
11+
{
12+
/**
13+
* Stream file into output stream
14+
*/
15+
case NORMAL;
16+
17+
/**
18+
* Simulate the zip to figure out the resulting file size
19+
*
20+
* This only supports entries where the file size is known beforehand and
21+
* deflation is disabled.
22+
*/
23+
case SIMULATE_STRICT;
24+
25+
/**
26+
* Simulate the zip to figure out the resulting file size
27+
*
28+
* If the file size is not known beforehand or deflation is enabled, the
29+
* entry streams will be read and rewound.
30+
*
31+
* If the entry does not support rewinding either, you will not be able to
32+
* use the same stream in a later operation mode like `NORMAL`.
33+
*/
34+
case SIMULATE_LAX;
35+
}

0 commit comments

Comments
 (0)