Commit e845d90b authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[SECURITY] Enclose file type scope when invoking ImageMagick

In order to enclose and avoid type guessing done by ImageMagick based
on mime-type and internal file content checks, new value object class
ImageMagickFile has been introduced as guard for those invocations.

Resolves: #87588
Releases: master, 9.5, 8.7
Security-Commit: d4f18684b2b2078b51cc7e93abdb251ea846984a
Security-Bulletin: TYPO3-CORE-SA-2019-012
Change-Id: I9a2dd74e8548530d7bc83bd18af2f4f0a8212019
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/60705

Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent f644cd72
......@@ -2427,8 +2427,11 @@ class GraphicalFunctions
*/
protected function executeIdentifyCommandForImageFile(string $imageFile): ?string
{
$frame = $this->addFrameSelection ? '[0]' : '';
$cmd = CommandUtility::imageMagickCommand('identify', '-format "%w %h %e %m" ' . CommandUtility::escapeShellArgument($imageFile . $frame));
$frame = $this->addFrameSelection ? 0 : null;
$cmd = CommandUtility::imageMagickCommand(
'identify',
'-format "%w %h %e %m" ' . ImageMagickFile::fromFilePath($imageFile, $frame)
);
$returnVal = [];
CommandUtility::exec($cmd, $returnVal);
$result = array_pop($returnVal);
......@@ -2453,8 +2456,13 @@ class GraphicalFunctions
}
// If addFrameSelection is set in the Install Tool, a frame number is added to
// select a specific page of the image (by default this will be the first page)
$frame = $this->addFrameSelection ? '[' . (int)$frame . ']' : '';
$cmd = CommandUtility::imageMagickCommand('convert', $params . ' ' . CommandUtility::escapeShellArgument($input . $frame) . ' ' . CommandUtility::escapeShellArgument($output));
$frame = $this->addFrameSelection ? (int)$frame : null;
$cmd = CommandUtility::imageMagickCommand(
'convert',
$params
. ' ' . ImageMagickFile::fromFilePath($input, $frame)
. ' ' . CommandUtility::escapeShellArgument($output)
);
$this->IM_commands[] = [$output, $cmd];
$ret = CommandUtility::exec($cmd);
// Change the permissions of the file
......@@ -2484,9 +2492,9 @@ class GraphicalFunctions
$parameters = '-compose over'
. ' -quality ' . $this->jpegQuality
. ' +matte '
. CommandUtility::escapeShellArgument($input) . ' '
. CommandUtility::escapeShellArgument($overlay) . ' '
. CommandUtility::escapeShellArgument($theMask) . ' '
. ImageMagickFile::fromFilePath($input) . ' '
. ImageMagickFile::fromFilePath($overlay) . ' '
. ImageMagickFile::fromFilePath($theMask) . ' '
. CommandUtility::escapeShellArgument($output);
$cmd = CommandUtility::imageMagickCommand('combine', $parameters);
$this->IM_commands[] = [$output, $cmd];
......@@ -2531,7 +2539,7 @@ class GraphicalFunctions
if (@rename($theFile, $temporaryName)) {
$cmd = CommandUtility::imageMagickCommand(
'convert',
implode(' ', CommandUtility::escapeShellArguments([$temporaryName, $theFile])),
ImageMagickFile::fromFilePath($temporaryName) . ' ' . CommandUtility::escapeShellArgument($theFile),
$gfxConf['processor_path_lzw']
);
CommandUtility::exec($cmd);
......@@ -2581,7 +2589,8 @@ class GraphicalFunctions
$newFile = Environment::getPublicPath() . '/typo3temp/assets/images/' . md5($theFile . '|' . filemtime($theFile)) . ($output_png ? '.png' : '.gif');
$cmd = CommandUtility::imageMagickCommand(
'convert',
implode(' ', CommandUtility::escapeShellArguments([$theFile, $newFile])),
ImageMagickFile::fromFilePath($theFile)
. ' ' . CommandUtility::escapeShellArgument($newFile),
$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']
);
CommandUtility::exec($cmd);
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Imaging;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Type\File\FileInfo;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Value object for file to be used for ImageMagick/GraphicsMagick invocation when
* being used as input file (implies and requires that file exists for some evaluations).
*/
class ImageMagickFile
{
/**
* Path to input file to be processed
*
* @var string
*/
protected $filePath;
/**
* Frame to be used (of multi-page document, e.g. PDF)
*
* @var int|null
*/
protected $frame;
/**
* Whether file actually exists
*
* @var bool
*/
protected $fileExists;
/**
* File extension as given in $filePath (e.g. 'file.png' -> 'png')
*
* @var string
*/
protected $fileExtension;
/**
* Resolved mime-type of file
*
* @var string
*/
protected $mimeType;
/**
* Resolved extension for mime-type (e.g. 'image/png' -> 'png')
* (might be empty if not defined in magic.mime database)
*
* @var string[]
* @see FileInfo::getMimeExtensions()
*/
protected $mimeExtensions = [];
/**
* Result to be used for ImageMagick/GraphicsMagick invocation containing
* combination of resolved format prefix, $filePath and frame escaped to be
* used as CLI argument (e.g. "'png:file.png'")
*
* @var string
*/
protected $asArgument;
/**
* File extensions that directly can be used (and are considered to be safe).
*
* @var string[]
*/
protected $allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'tif', 'tiff', 'bmp', 'pcx', 'tga', 'ico'];
/**
* File extensions that never shall be used.
*
* @var string[]
*/
protected $deniedExtensions = ['epi', 'eps', 'eps2', 'eps3', 'epsf', 'epsi', 'ept', 'ept2', 'ept3', 'msl', 'ps', 'ps2', 'ps3'];
/**
* File mime-types that have to be matching. Adding custom mime-types is possible using
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType']
*
* @var string[]
* @see FileInfo::getMimeExtensions()
*/
protected $mimeTypeExtensionMap = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/heic' => 'heic',
'image/heif' => 'heif',
'image/webp' => 'webp',
'image/svg' => 'svg',
'image/svg+xml' => 'svg',
'image/tiff' => 'tif',
'application/pdf' => 'pdf',
];
/**
* @param string $filePath
* @param int|null $frame
* @return ImageMagickFile
*/
public static function fromFilePath(string $filePath, int $frame = null): self
{
return GeneralUtility::makeInstance(
static::class,
$filePath,
$frame
);
}
/**
* @param string $filePath
* @param int|null $frame
* @throws Exception
*/
public function __construct(string $filePath, int $frame = null)
{
$this->frame = $frame;
$this->fileExists = file_exists($filePath);
$this->filePath = $filePath;
$this->fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($this->fileExists) {
$fileInfo = $this->getFileInfo($filePath);
$this->mimeType = $fileInfo->getMimeType();
$this->mimeExtensions = $fileInfo->getMimeExtensions();
}
$this->asArgument = $this->escape(
$this->resolvePrefix() . $this->filePath
. ($this->frame !== null ? '[' . $this->frame . ']' : '')
);
}
/**
* @return string
*/
public function __toString(): string
{
return $this->asArgument;
}
/**
* Resolves according ImageMagic/GraphicsMagic format (e.g. 'png:', 'jpg:', ...).
* + in case mime-type could be resolved and is configured, it takes precedence
* + otherwise resolved mime-type extension of mime.magick database is used if available
* (includes custom settings with $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'])
* + otherwise "safe" and allowed file extension is used (jpg, png, gif, webp, tif, ...)
* + potentially malicious script formats (eps, ps, ...) are not allowed
*
* @return string
* @throws Exception
*/
protected function resolvePrefix(): string
{
$prefixExtension = null;
$fileExtension = strtolower($this->fileExtension);
if ($this->mimeType !== null && !empty($this->mimeTypeExtensionMap[$this->mimeType])) {
$prefixExtension = $this->mimeTypeExtensionMap[$this->mimeType];
} elseif (!empty($this->mimeExtensions) && strpos((string)$this->mimeType, 'image/') === 0) {
$prefixExtension = $this->mimeExtensions[0];
} elseif ($this->isInAllowedExtensions($fileExtension)) {
$prefixExtension = $fileExtension;
}
if ($prefixExtension !== null && !in_array(strtolower($prefixExtension), $this->deniedExtensions, true)) {
return $prefixExtension . ':';
}
throw new Exception(
sprintf(
'Unsupported file %s (%s)',
basename($this->filePath),
$this->mimeType ?? 'unknown'
),
1550060977
);
}
/**
* @param string $value
* @return string
*/
protected function escape(string $value): string
{
return CommandUtility::escapeShellArgument($value);
}
/**
* @param string $extension
* @return bool
*/
protected function isInAllowedExtensions(string $extension): bool
{
return in_array($extension, $this->allowedExtensions, true);
}
/**
* @param string $filePath
* @return FileInfo
*/
protected function getFileInfo(string $filePath): FileInfo
{
return GeneralUtility::makeInstance(FileInfo::class, $filePath);
}
}
......@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Core\Resource\OnlineMedia\Processing;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Imaging\ImageMagickFile;
use TYPO3\CMS\Core\Resource\Driver\DriverInterface;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry;
......@@ -138,11 +139,10 @@ class PreviewProcessing
$arguments = CommandUtility::escapeShellArguments([
'width' => $configuration['width'],
'height' => $configuration['height'],
'originalFileName' => $originalFileName,
'temporaryFileName' => $temporaryFileName,
]);
$parameters = '-sample ' . $arguments['width'] . 'x' . $arguments['height'] . ' '
. $arguments['originalFileName'] . '[0] ' . $arguments['temporaryFileName'];
$parameters = '-sample ' . $arguments['width'] . 'x' . $arguments['height']
. ' ' . ImageMagickFile::fromFilePath($originalFileName, 0)
. ' ' . CommandUtility::escapeShellArgument($temporaryFileName);
$cmd = CommandUtility::imageMagickCommand('convert', $parameters) . ' 2>&1';
CommandUtility::exec($cmd);
......
......@@ -15,6 +15,7 @@ namespace TYPO3\CMS\Core\Resource\Processing;
*/
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Imaging\ImageMagickFile;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -151,11 +152,10 @@ class LocalPreviewHelper
$arguments = CommandUtility::escapeShellArguments([
'width' => $configuration['width'],
'height' => $configuration['height'],
'originalFileName' => $originalFileName,
'targetFilePath' => $targetFilePath,
]);
$parameters = '-sample ' . $arguments['width'] . 'x' . $arguments['height'] . ' '
. $arguments['originalFileName'] . '[0] ' . $arguments['targetFilePath'];
$parameters = '-sample ' . $arguments['width'] . 'x' . $arguments['height']
. ' ' . ImageMagickFile::fromFilePath($originalFileName, 0)
. ' ' . CommandUtility::escapeShellArgument($targetFilePath);
$cmd = CommandUtility::imageMagickCommand('convert', $parameters) . ' 2>&1';
CommandUtility::exec($cmd);
......
......@@ -13,7 +13,9 @@ namespace TYPO3\CMS\Core\Type\File;
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Type\TypeInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A SPL FileInfo class providing general information related to a file.
......@@ -23,6 +25,9 @@ class FileInfo extends \SplFileInfo implements TypeInterface
/**
* Return the mime type of a file.
*
* TYPO3 specific settings in $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'] take
* precedence over native resolving.
*
* @return string|bool Returns the mime type or FALSE if the mime type could not be discovered
*/
public function getMimeType()
......@@ -48,7 +53,7 @@ class FileInfo extends \SplFileInfo implements TypeInterface
'mimeType' => &$mimeType
];
\TYPO3\CMS\Core\Utility\GeneralUtility::callUserFunction(
GeneralUtility::callUserFunction(
$mimeTypeGuesser,
$hookParameters,
$this
......@@ -57,4 +62,40 @@ class FileInfo extends \SplFileInfo implements TypeInterface
return $mimeType;
}
/**
* Returns the file extensions appropiate for a the MIME type detected in the file. For types that commonly have
* multiple file extensions, such as JPEG images, then the return value is multiple extensions, for instance that
* could be ['jpeg', 'jpg', 'jpe', 'jfif']. For unknown types not available in the magic.mime database
* (/etc/magic.mime, /etc/mime.types, ...), then return value is an empty array.
*
* TYPO3 specific settings in $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'] take
* precedence over native resolving.
*
* @return string[]
*/
public function getMimeExtensions(): array
{
$mimeExtensions = [];
if ($this->isFile()) {
$fileExtensionToMimeTypeMapping = $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'];
$mimeType = $this->getMimeType();
if (in_array($mimeType, $fileExtensionToMimeTypeMapping, true)) {
$mimeExtensions = array_keys($fileExtensionToMimeTypeMapping, $mimeType, true);
} elseif (function_exists('finfo_file')) {
$fileInfo = new \finfo();
$mimeExtensions = array_filter(
GeneralUtility::trimExplode(
'/',
(string)$fileInfo->file($this->getPathname(), FILEINFO_EXTENSION)
),
function ($item) {
// filter invalid items ('???' is used if not found in magic.mime database)
return $item !== '' && $item !== '???';
}
);
}
}
return $mimeExtensions;
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
width="481.71875"
height="203.5625"
id="svg7592">
<defs
id="defs7594" />
<metadata
id="metadata7597">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(258,-219.15625)"
id="layer1">
<path
d="m 140.60001,371.50349 c -5.205,0 -12.96125,-1.59375 -13.9175,-1.81 l 0,-7.75125 c 2.55125,0.53 9.135,1.62875 13.8125,1.62875 5.41625,0 8.9225,-4.60625 8.9225,-12.78375 0,-9.66875 -1.59125,-14.7675 -9.135,-14.7675 l -8.7125,0 0,-7.755 7.64875,0 c 8.6075,0 9.03,-8.81875 9.03,-13.0675 0,-8.395 -2.65625,-11.79375 -7.96625,-11.79375 -4.675,0 -9.98875,1.16875 -13.06875,1.80625 l 0,-7.75375 c 1.17,-0.21375 7.43875,-1.80625 12.855,-1.80625 10.94375,0 17.21125,4.67375 17.21125,20.505 0,7.22375 -2.55125,13.59625 -8.18125,15.61625 6.47875,0.42375 9.45375,7.54125 9.45375,17.95375 0,15.82875 -6.15875,21.77875 -17.9525,21.77875 m -48.867497,-68.1 c -9.55875,0 -12.74875,6.48375 -12.74875,29.85375 0,22.8425 3.19,30.49125 12.74875,30.49125 9.561247,0 12.748747,-7.64875 12.748747,-30.49125 0,-23.37 -3.1875,-29.85375 -12.748747,-29.85375 m 0,68.1 c -17.52875,0 -22.205,-12.74875 -22.205,-38.77625 0,-24.9675 4.67625,-37.0775 22.205,-37.0775 17.529997,0 22.202497,12.11 22.202497,37.0775 0,26.0275 -4.6725,38.77625 -22.202497,38.77625 m -52.91,-68.20375 c -5.845,0 -9.98625,0.63625 -9.98625,0.63625 l 0,31.02 9.98625,0 c 5.94875,0 10.0925,-3.93125 10.0925,-15.51 0,-10.625 -2.55,-16.14625 -10.0925,-16.14625 m -1.0625,39.4125 -8.92375,0 0,28.045 -9.2425,0 0,-74.365 c 0,0 9.13625,-0.7425 17.95375,-0.7425 16.14875,0 20.825,9.985 20.825,23.0525 0,16.15 -5.52625,24.01 -20.6125,24.01 m -48.0175,-6.48 0,34.525 -9.56125,0 0,-34.525 -19.015,-39.84 10.19625,0 14.02375,30.065 14.02374986,-30.065 9.66625004,0 -19.3337499,39.84 z m -49.58,-31.76375 0,66.28875 -9.24125,0 0,-66.28875 -16.36125,0 0,-8.07625 41.9625,0 0,8.07625 -16.36,0 z"
id="path5771"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m -129.96886,340.07111 c -1.50125,0.4425 -2.6975,0.595 -4.2625,0.595 -12.84,0 -31.7,-44.87 -31.7,-59.80375 0,-5.50125 1.30625,-7.335 3.1425,-8.90625 -15.7175,1.8325 -34.58,7.5975 -40.6075,14.9325 -1.30875,1.835 -2.095,4.71625 -2.095,8.3825 0,23.3175 24.8875,76.23375 42.44125,76.23375 8.12,0 21.81625,-13.36 33.08125,-31.43375"
id="path5775"
style="fill:#ff8700;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m -138.16461,270.38299 c 16.2425,0 32.4875,2.62 32.4875,11.78875 0,18.60125 -11.78875,41.13125 -17.815,41.13125 -10.74,0 -24.10125,-29.86375 -24.10125,-44.7975 0,-6.81125 2.62,-8.1225 9.42875,-8.1225"
id="path5779"
style="fill:#ff8700;fill-opacity:1;fill-rule:nonzero;stroke:none" />
</g>
</svg>
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Tests\Functional\Imaging;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\vfsStreamDirectory;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Imaging\ImageMagickFile;
use TYPO3\CMS\Core\Type\File\FileInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
class ImageMagickFileTest extends FunctionalTestCase
{
/**
* @var vfsStreamDirectory
*/
private $directory;
protected function setUp()
{
parent::setUp();
$fixturePath = __DIR__ . '/Fixtures';
$structure = [];
$this->addFiles($structure, ['file.ai', 'file.ai.jpg'], $fixturePath . '/file.ai');
$this->addFiles($structure, ['file.bmp', 'file.bmp.jpg'], $fixturePath . '/file.bmp');
$this->addFiles($structure, ['file.gif', 'file.gif.jpg'], $fixturePath . '/file.gif');
$this->addFiles($structure, ['file.fax', 'file.fax.jpg'], $fixturePath . '/file.fax');
$this->addFiles($structure, ['file.jpg', 'file.jpg.png'], $fixturePath . '/file.jpg');
$this->addFiles($structure, ['file.png', 'file.png.jpg'], $fixturePath . '/file.png');
$this->addFiles($structure, ['file.svg', 'file.svg.jpg'], $fixturePath . '/file.svg');
$this->addFiles($structure, ['file.tif', 'file.tif.jpg'], $fixturePath . '/file.tif');
$this->addFiles($structure, ['file.webp', 'file.webp.jpg'], $fixturePath . '/file.webp');
$this->addFiles($structure, ['file.pdf', 'file.pdf.jpg'], $fixturePath . '/file.pdf');
$this->addFiles($structure, ['file.ps', 'file.ps.jpg'], $fixturePath . '/file.ps');
$this->addFiles($structure, ['file.eps', 'file.eps.jpg'], $fixturePath . '/file.eps');
$this->directory = vfsStream::setup('root', null, $structure);
}
protected function tearDown()
{
unset($this->directory);
parent::tearDown();
}
/**
* @return array
*/
public function framesAreConsideredDataProvider(): array
{
return [
'file.pdf' => ['file.pdf', null, '\'pdf:{directory}/file.pdf\''],
'file.pdf[0]' => ['file.pdf', 0, '\'pdf:{directory}/file.pdf[0]\''],
];
}
/**
* @param string $fileName
* @param int|null $frame
* @param string $expectation
*
* @test
* @dataProvider framesAreConsideredDataProvider
*/
public function framesAreConsidered(string $fileName, ?int $frame, string $expectation)
{
$expectation = $this->substituteVariables($expectation);
$filePath = sprintf('%s/%s', $this->directory->url(), $fileName);
$file = ImageMagickFile::fromFilePath($filePath, $frame);
self::assertSame($expectation, (string)$file);
}
/**
* @return array
*/
public function resultIsEscapedDataProvider(): array
{
// probably Windows system
if (DIRECTORY_SEPARATOR === '\\') {
return [
'without frame' => ['file.pdf', null, '"pdf:{directory}/file.pdf"'],
'with first frame' => ['file.pdf', 0, '"pdf:{directory}/file.pdf[0]"'],
'special literals' => ['\'`%$!".png', 0, '"png:{directory}/\'` $ .png[0]"'],
];
}
// probably Unix system
return [
'without frame' => ['file.pdf', null, '\'pdf:{directory}/file.pdf\''],
'with first frame' => ['file.pdf', 0, '\'pdf:{directory}/file.pdf[0]\''],
'special literals' => ['\'`%$!".png', 0, '\'png:{directory}/\'\\\'\'`%$!".png[0]\''],
];
}
/**
* @param string $fileName
* @param int|null $frame
* @param string $expectation
*
* @test
* @dataProvider resultIsEscapedDataProvider
*/
public function resultIsEscaped(string $fileName, ?int $frame, string $expectation)
{
$expectation = $this->substituteVariables($expectation);
$filePath = sprintf('%s/%s', $this->directory->url(), $fileName);
$file = ImageMagickFile::fromFilePath($filePath, $frame);
self::assertSame($expectation, (string)$file);