Skip to content

Commit a63243e

Browse files
committed
fix: harden mutation hotspots
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent b9420b4 commit a63243e

4 files changed

Lines changed: 555 additions & 50 deletions

File tree

src/Layout/TextOverflowTruncator.php

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,13 @@ public function forceEllipsis(
3636
float $fontSize,
3737
): string {
3838
$ellipsis = '...';
39+
$ellipsisWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $ellipsis);
3940
$characters = $this->splitCharacters($text);
4041

41-
while ($characters !== []) {
42-
$candidate = implode('', $characters) . $ellipsis;
43-
if ($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth) {
44-
return rtrim(implode('', $characters)) . $ellipsis;
42+
foreach ($this->buildCandidates($characters) as $candidate) {
43+
if (($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + $ellipsisWidth) <= $maxWidth) {
44+
return rtrim($candidate) . $ellipsis;
4545
}
46-
47-
array_pop($characters);
4846
}
4947

5048
return $ellipsis;
@@ -59,4 +57,21 @@ private function splitCharacters(string $text): array
5957

6058
return $characters === false ? [] : $characters;
6159
}
60+
61+
/**
62+
* @param list<string> $characters
63+
* @return list<string>
64+
*/
65+
private function buildCandidates(array $characters): array
66+
{
67+
$candidates = [];
68+
$candidate = '';
69+
70+
foreach ($characters as $character) {
71+
$candidate .= $character;
72+
$candidates[] = $candidate;
73+
}
74+
75+
return array_reverse($candidates);
76+
}
6277
}

src/Pdf/FilesystemPdfImageEmbedder.php

Lines changed: 138 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,11 @@
1313
{
1414
public function embed(string $source): EmbeddedPdfImage
1515
{
16-
if (!is_file($source) || !is_readable($source)) {
17-
throw new InvalidArgumentException(sprintf('Image source "%s" must be a readable file.', $source));
18-
}
19-
20-
$contents = file_get_contents($source);
21-
if ($contents === false) {
22-
throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source));
23-
}
24-
25-
$imageInfo = getimagesizefromstring($contents);
26-
if ($imageInfo === false || !isset($imageInfo['mime'])) {
27-
throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source));
28-
}
16+
$this->assertReadableSource($source);
2917

30-
$mime = $imageInfo['mime'];
18+
$contents = $this->readSourceContents($source);
19+
$imageInfo = $this->detectImageInfo($contents, $source);
20+
$mime = $this->resolveMimeType($imageInfo, $source);
3121

3222
return match ($mime) {
3323
'image/jpeg' => $this->embedJpeg($contents, $imageInfo),
@@ -45,20 +35,16 @@ private function embedJpeg(string $contents, array $imageInfo): EmbeddedPdfImage
4535
{
4636
$width = $imageInfo[0] ?? null;
4737
$height = $imageInfo[1] ?? null;
48-
if (!is_int($width) || !is_int($height)) {
38+
39+
if (!is_int($width)) {
4940
throw new InvalidArgumentException('JPEG metadata must expose width and height.');
5041
}
5142

52-
$channels = $imageInfo['channels'] ?? 3;
53-
if (!is_int($channels)) {
54-
$channels = 3;
43+
if (!is_int($height)) {
44+
throw new InvalidArgumentException('JPEG metadata must expose width and height.');
5545
}
5646

57-
$colorSpace = match ($channels) {
58-
1 => '/DeviceGray',
59-
4 => '/DeviceCMYK',
60-
default => '/DeviceRGB',
61-
};
47+
$colorSpace = $this->resolveJpegColorSpace($imageInfo['channels'] ?? null);
6248

6349
return new EmbeddedPdfImage(
6450
dictionary: [
@@ -136,11 +122,15 @@ private function parsePng(string $contents): array
136122
{
137123
$this->assertPngSignature($contents);
138124

125+
$contentLength = strlen($contents);
139126
$offset = 8;
140127
$header = null;
141128
$idat = '';
129+
$iendOffset = null;
130+
131+
while (($contentLength - $offset) >= 12) {
132+
$this->assertNoPngChunksAfterIend($iendOffset);
142133

143-
while ($offset + 8 <= strlen($contents)) {
144134
['data' => $data, 'type' => $type] = $this->readPngChunk($contents, $offset);
145135

146136
if ($type === 'IHDR') {
@@ -152,10 +142,16 @@ private function parsePng(string $contents): array
152142
}
153143

154144
if ($type === 'IEND') {
155-
break;
145+
$iendOffset = $offset;
156146
}
157147
}
158148

149+
if ($iendOffset === null) {
150+
throw new InvalidArgumentException('PNG trailer chunk is missing.');
151+
}
152+
153+
$this->assertPngEndsAtIend($iendOffset, $contentLength);
154+
159155
if ($header === null) {
160156
throw new InvalidArgumentException('PNG metadata is incomplete.');
161157
}
@@ -186,21 +182,23 @@ private function assertPngSignature(string $contents): void
186182
*/
187183
private function readPngChunk(string $contents, int &$offset): array
188184
{
189-
$chunkLength = unpack('Nvalue', substr($contents, $offset, 4));
185+
$chunkLengthBytes = substr($contents, $offset, 4);
186+
$chunkLength = $this->parseChunkLength($chunkLengthBytes);
187+
190188
$offset += 4;
191189
$type = substr($contents, $offset, 4);
192190
$offset += 4;
193191

194-
if ($chunkLength === false || !isset($chunkLength['value'])) {
195-
throw new InvalidArgumentException('Invalid PNG chunk length.');
192+
if (strlen($type) !== 4) {
193+
throw new InvalidArgumentException('Invalid PNG chunk type.');
196194
}
197195

198-
$data = substr($contents, $offset, $chunkLength['value']);
199-
if (strlen($data) !== $chunkLength['value']) {
196+
$data = substr($contents, $offset, $chunkLength);
197+
if (strlen($data) !== $chunkLength) {
200198
throw new InvalidArgumentException('PNG chunk data is truncated.');
201199
}
202200

203-
$offset += $chunkLength['value'] + 4;
201+
$offset += $chunkLength + 4;
204202

205203
return [
206204
'data' => $data,
@@ -221,13 +219,14 @@ private function readPngChunk(string $contents, int &$offset): array
221219
*/
222220
private function parsePngHeader(string $data): array
223221
{
222+
if (strlen($data) !== 13) {
223+
throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.');
224+
}
225+
224226
$header = unpack(
225227
'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace',
226228
$data,
227229
);
228-
if ($header === false) {
229-
throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.');
230-
}
231230

232231
return $header;
233232
}
@@ -285,8 +284,8 @@ private function createImageDictionary(int $width, int $height, string $colorSpa
285284
*/
286285
private function unfilterPngScanlines(string $idat, int $height, int $rowLength, int $bytesPerPixel): array
287286
{
288-
$inflated = gzuncompress($idat);
289-
if ($inflated === false) {
287+
$inflated = @gzuncompress($idat);
288+
if (!is_string($inflated)) {
290289
throw new InvalidArgumentException('PNG image data could not be decompressed.');
291290
}
292291

@@ -323,12 +322,13 @@ private function unfilterPngRow(
323322
): string {
324323
$row = '';
325324
$rowLength = strlen($filteredRow);
325+
$previousRowWithPadding = str_repeat("\x00", $bytesPerPixel) . $previousRow;
326326

327327
for ($index = 0; $index < $rowLength; $index++) {
328328
$rawByte = ord($filteredRow[$index]);
329329
$left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0;
330330
$above = ord($previousRow[$index]);
331-
$upperLeft = $index >= $bytesPerPixel ? ord($previousRow[$index - $bytesPerPixel]) : 0;
331+
$upperLeft = ord($previousRowWithPadding[$index]);
332332

333333
$decodedByte = match ($filterType) {
334334
0 => $rawByte,
@@ -354,14 +354,110 @@ private function paethPredictor(int $left, int $above, int $upperLeft): int
354354
$aboveDistance = abs($prediction - $above);
355355
$upperLeftDistance = abs($prediction - $upperLeft);
356356

357-
if ($leftDistance <= $aboveDistance && $leftDistance <= $upperLeftDistance) {
358-
return $left;
357+
$bestDistance = $leftDistance;
358+
$bestValue = $left;
359+
360+
if ($aboveDistance < $bestDistance) {
361+
$bestDistance = $aboveDistance;
362+
$bestValue = $above;
363+
}
364+
365+
if ($upperLeftDistance < $bestDistance) {
366+
return $upperLeft;
367+
}
368+
369+
return $bestValue;
370+
}
371+
372+
private function parseChunkLength(string $chunkLengthBytes): int
373+
{
374+
if (strlen($chunkLengthBytes) !== 4) {
375+
throw new InvalidArgumentException('Invalid PNG chunk length.');
376+
}
377+
378+
return (ord($chunkLengthBytes[0]) << 24)
379+
| (ord($chunkLengthBytes[1]) << 16)
380+
| (ord($chunkLengthBytes[2]) << 8)
381+
| ord($chunkLengthBytes[3]);
382+
}
383+
384+
private function assertNoPngChunksAfterIend(?int $iendOffset): void
385+
{
386+
if ($iendOffset !== null) {
387+
throw new InvalidArgumentException('PNG data after IEND is not supported.');
388+
}
389+
}
390+
391+
private function assertPngEndsAtIend(int $iendOffset, int $contentLength): void
392+
{
393+
if ($iendOffset !== $contentLength) {
394+
throw new InvalidArgumentException('PNG data after IEND is not supported.');
395+
}
396+
}
397+
398+
private function assertReadableSource(string $source): void
399+
{
400+
if (!is_file($source)) {
401+
throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source));
402+
}
403+
404+
if (!is_readable($source)) {
405+
throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source));
406+
}
407+
}
408+
409+
private function readSourceContents(string $source): string
410+
{
411+
$contents = @file_get_contents($source);
412+
if (!is_string($contents)) {
413+
throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source));
414+
}
415+
416+
return $contents;
417+
}
418+
419+
/**
420+
* @return array<int|string, mixed>
421+
*/
422+
private function detectImageInfo(string $contents, string $source): array
423+
{
424+
$imageInfo = getimagesizefromstring($contents);
425+
if (!is_array($imageInfo)) {
426+
throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source));
427+
}
428+
429+
return $imageInfo;
430+
}
431+
432+
/**
433+
* @param array<int|string, mixed> $imageInfo
434+
*/
435+
private function resolveMimeType(array $imageInfo, string $source): string
436+
{
437+
if (!array_key_exists('mime', $imageInfo)) {
438+
throw new InvalidArgumentException(sprintf(
439+
'Image metadata for "%s" does not expose a mime type.',
440+
$source,
441+
));
359442
}
360443

361-
if ($aboveDistance <= $upperLeftDistance) {
362-
return $above;
444+
$mime = $imageInfo['mime'];
445+
if (!is_string($mime)) {
446+
throw new InvalidArgumentException(sprintf(
447+
'Image metadata for "%s" must expose the mime type as a string.',
448+
$source,
449+
));
363450
}
364451

365-
return $upperLeft;
452+
return $mime;
453+
}
454+
455+
private function resolveJpegColorSpace(mixed $channels): string
456+
{
457+
return match ($channels) {
458+
1 => '/DeviceGray',
459+
4 => '/DeviceCMYK',
460+
default => '/DeviceRGB',
461+
};
366462
}
367463
}

tests/Unit/Layout/TextOverflowTruncatorTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,15 @@ public function testForceEllipsisReturnsOnlyTheMarkerForInvalidUtf8Input(): void
6060

6161
self::assertSame('...', $truncator->forceEllipsis("\xc3\x28", 10.0, 'F1', 10.0));
6262
}
63+
64+
public function testForceEllipsisUsesSuffixWidthWhenCheckingFit(): void
65+
{
66+
$metrics = new StandardFontMetrics();
67+
$truncator = new TextOverflowTruncator($metrics);
68+
69+
self::assertSame(
70+
'i...',
71+
$truncator->forceEllipsis('iW', $metrics->measureString('F1', 10.0, 'i...'), 'F1', 10.0),
72+
);
73+
}
6374
}

0 commit comments

Comments
 (0)